From aca105f74e4cdd2428b5df8bbb6870c0d72a5c9d Mon Sep 17 00:00:00 2001 From: spacedouut Date: Wed, 26 Aug 2026 11:34:55 -0400 Subject: [PATCH 1/8] spike: pluggable backend interface (captured/sunshine/vnc/rdp), remove MoQ --- AGENTS.md | 44 ++-- go.mod | 4 +- go.sum | 12 -- src/backend/backend.go | 115 +++++++++++ src/backend/captured.go | 335 ++++++++++++++++++++++++++++++ src/backend/rdp.go | 44 ++++ src/backend/sunshine.go | 110 ++++++++++ src/backend/vnc.go | 68 +++++++ src/captured.go | 65 ------ src/main.go | 181 +++++++++------- src/moq_adapter.go | 104 ---------- src/session.go | 5 +- src/stream.go | 301 ++++----------------------- src/types.go | 42 ++-- src/web/index.html | 63 +++--- src/web/package.json | 16 ++ src/web/style.css | 442 ++++++++++++++++++++++++++++++++++++++++ src/web/tsconfig.json | 19 ++ src/web/vite-env.d.ts | 67 ++++++ src/web/vite.config.ts | 19 ++ 20 files changed, 1452 insertions(+), 604 deletions(-) create mode 100644 src/backend/backend.go create mode 100644 src/backend/captured.go create mode 100644 src/backend/rdp.go create mode 100644 src/backend/sunshine.go create mode 100644 src/backend/vnc.go delete mode 100644 src/captured.go delete mode 100644 src/moq_adapter.go create mode 100644 src/web/package.json create mode 100644 src/web/style.css create mode 100644 src/web/tsconfig.json create mode 100644 src/web/vite-env.d.ts create mode 100644 src/web/vite.config.ts diff --git a/AGENTS.md b/AGENTS.md index f747fe8..00574bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,15 +37,7 @@ JSON control messages (bidirectional stream): {"type":"fingerprint-refresh","algorithm":"sha-256","fingerprint":""} // sent on connect + cert rotation ``` -Video: 64KB chunks of H.264/H.265/AV1 Annex B byte stream over the unidirectional stream. - -**Media over QUIC (MoQ)**: `https://:52020/moq` — separate WebTransport session using `@moq/lite`. -- gomoqt `WebTransportHandler` with `UpgradeFunc` wrapping via `okdaichi/webtransport-go` -- `PublishFunc("/video", ...)` registers each subscriber's `TrackWriter` -- Each ffmpeg chunk → one MoQ group → one frame -- Old uni-stream model kept for backwards compat; MoQ runs alongside it. -- gomoqt v0.15.0, falls back to IETF/moql mode (no ALPN h3/moq). - +**Endpoint**: `https://:52020/wt` (WebTransport, QUIC over UDP) ### Cert system - Self-signed ECDSA P-256 certificate, 13-day validity @@ -67,17 +59,7 @@ await transport.ready; const stream = await transport.createBidirectionalStream(); ``` -MoQ video connection: -```js -const moqTransport = new WebTransport(`https://${ip}:52020/moq`, { - serverCertificateHashes: [{ - algorithm: "sha-256", - value: new Uint8Array(fingerprintBytes) - }] -}); -await moqTransport.ready; -// Use @moq/lite to subscribe to "/video" -``` +MoQ has been removed from the agent (was `/moq` + gomoqt). Raw WT uni-stream is the only media path. ### Web UI @@ -95,17 +77,25 @@ Embedded HTML at `http://:52022/` showing: | `--fingerprint` | Print SHA-256 fingerprint and exit | | `--cert cert.pem` | Custom TLS certificate (ECDSA P-256 PEM) | | `--key key.pem` | Custom TLS private key (ECDSA P-256 PEM) | +| `--backend auto\|captured\|sunshine\|vnc\|rdp` | Video backend (auto probes in order captured → sunshine → vnc → rdp) | +| `--captured "source=...,device=..."` | captured backend opts (source/device for Spike B pipelines) | +| `--sunshine "addr=host:47989"` | Sunshine/Moonlight host address | +| `--vnc "addr=host:5901"` | VNC server address | +| `--rdp "addr=host:3389"` | RDP server address | +| `--dry-run` | List displays via the selected backend and exit | ### Architecture ``` -captured (Unix sockets) - └─ raw BGRA frames → agent - ├─ ffmpeg (GPU encode via VideoToolbox/NVENC/AMF/QSV/VAAPI/libx264) - │ └─ H.264/H.265/AV1 Annex B byte stream → stdout - └─ publishStream goroutine - ├─ writes 64KB chunks to each subscriber's unidirectional stream - └─ writes each chunk as a MoQ frame/group to each MoQ TrackWriter +backend.Backend { ListDisplays; StartStream -> Stream <-chan H264Chunk } (src/backend/backend.go) + ├─ captured — unix-socket daemon + ffmpeg encode (src/backend/captured.go) + ├─ sunshine — Moonlight RTSP passthrough H264 (STUB, src/backend/sunshine.go) + ├─ vnc — RFB frame polling (STUB, src/backend/vnc.go) + └─ rdp — MS-RDPBCGR (STUB, src/backend/rdp.go) + +activeBackend (chosen via --backend at startup): + └─ StartStream → Stream.Chunks() channel + └─ publishStream goroutine writes each chunk to every subscriber's WT uni stream ``` ### Start sequence diff --git a/go.mod b/go.mod index 993c8fa..d95ca6c 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,13 @@ module distancedesktop/agent go 1.26.3 require ( - github.com/quic-go/quic-go v0.59.1 github.com/okdaichi/webtransport-go v0.10.2-okdaichi.1 + github.com/quic-go/quic-go v0.59.1 ) require ( github.com/dunglas/httpsfv v1.1.0 // indirect - github.com/okdaichi/webtransport-go v0.10.2-okdaichi.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/qumo-dev/gomoqt v0.15.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect diff --git a/go.sum b/go.sum index 7078eb1..497c068 100644 --- a/go.sum +++ b/go.sum @@ -10,28 +10,16 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI= -github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow= -github.com/qumo-dev/gomoqt v0.15.0 h1:cUxHbOVvyAu0/9QG4LpVQfLnnV+pSPxVfkrhAgQbrXY= -github.com/qumo-dev/gomoqt v0.15.0/go.mod h1:q4FGmnVZ3Cn098Y3rYLiILEH+hhftsIN9maGlA6R7UM= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/src/backend/backend.go b/src/backend/backend.go new file mode 100644 index 0000000..694ff4c --- /dev/null +++ b/src/backend/backend.go @@ -0,0 +1,115 @@ +// Package backend defines the pluggable capture/stream backend interface +// for the distance agent. +// +// A Backend lists displays and produces an H.264 Annex B chunk stream that +// the WebTransport layer fans out to subscribers. Concrete backends live in +// this package (captured, sunshine, vnc, rdp) and self-register via init(). +package backend + +import ( + "context" + "fmt" + "sort" + "sync" +) + +// Display describes one capturable output. +type Display struct { + ID uint32 `json:"id"` + Width int `json:"width"` + Height int `json:"height"` + X int `json:"x"` + Y int `json:"y"` + RefreshRate float64 `json:"refresh_rate"` +} + +// StartRequest parameterizes a stream start. +type StartRequest struct { + DisplayID uint32 + FPS int + Codec string // h264 | hevc | av1 | vp9 + Bitrate int // bits/sec, 0 = backend default +} + +// H264Chunk is a piece of H.264 Annex B data ready for transport. +type H264Chunk struct { + Data []byte + Keyframe bool +} + +// Stream is a live video stream from a backend. +type Stream interface { + // Chunks yields encoded H.264 chunks until the stream ends, then closes. + Chunks() <-chan H264Chunk + Width() int + Height() int + FPS() int + Codec() string + // Close tears down capture + encode; idempotent. + Close() error +} + +// Backend is a pluggable video source. +type Backend interface { + Name() string + ListDisplays(ctx context.Context) ([]Display, error) + StartStream(ctx context.Context, req StartRequest) (Stream, error) +} + +var ( + regMu sync.Mutex + registry = map[string]Backend{} + AutoOrder = []string{"captured", "sunshine", "vnc", "rdp"} +) + +// Register adds a backend to the registry. Later registration of the same +// name replaces the earlier entry. +func Register(b Backend) { + regMu.Lock() + defer regMu.Unlock() + registry[b.Name()] = b +} + +// Get returns the named backend. +func Get(name string) (Backend, error) { + regMu.Lock() + defer regMu.Unlock() + b, ok := registry[name] + if !ok { + return nil, fmt.Errorf("backend: unknown backend %q (available: %v)", name, Names()) + } + return b, nil +} + +// Names returns registered backend names sorted. +func Names() []string { + regMu.Lock() + defer regMu.Unlock() + out := make([]string, 0, len(registry)) + for n := range registry { + out = append(out, n) + } + sort.Strings(out) + return out +} + +// AutoCandidate is one AutoOrder step result used by callers implementing +// `--backend auto`. +type AutoCandidate struct { + Name string + Backend Backend +} + +// Candidates returns backends in auto-probing order (registration order is +// irrelevant; AutoOrder wins). +func Candidates() []AutoCandidate { + regMu.Lock() + defer regMu.Unlock() + var out []AutoCandidate + for _, name := range AutoOrder { + if b, ok := registry[name]; ok { + out = append(out, AutoCandidate{Name: name, Backend: b}) + } + } + return out +} diff --git a/src/backend/captured.go b/src/backend/captured.go new file mode 100644 index 0000000..afe62b1 --- /dev/null +++ b/src/backend/captured.go @@ -0,0 +1,335 @@ +package backend + +// Backend "captured": talks to the distancedesktop/captured unix-socket +// daemon. Control channel speaks JSON requests/responses; the daemon hands +// back a media socket carrying raw BGRA frames (8-byte big-endian +// width/height header on the first frame, then per-frame headers), which we +// pipe through ffmpeg to produce H.264 Annex B. +// +// The wire protocol is unchanged — existing captured daemons keep working. + +import ( + "bufio" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "os" + "os/exec" + "strconv" + "strings" + "sync" +) + +func init() { Register(&CapturedBackend{}) } + +// CapturedBackend captures via the captured daemon + ffmpeg encode. +type CapturedBackend struct{} + +func (b *CapturedBackend) Name() string { return "captured" } + +// SocketPath returns the captured control socket path. +func (b *CapturedBackend) SocketPath() string { + if v := os.Getenv("CAPTURED_SOCKET"); v != "" { + return v + } + return "/tmp/captured.socket" +} + +type capturedDisplayResp struct { + Displays []struct { + ID uint32 `json:"id"` + Width int `json:"width"` + Height int `json:"height"` + X int `json:"x"` + Y int `json:"y"` + RefreshRate float64 `json:"refresh_rate"` + } `json:"displays"` + Error string `json:"error,omitempty"` +} + +// ListDisplays queries the captured daemon over its unix control socket. +func (b *CapturedBackend) ListDisplays(ctx context.Context) ([]Display, error) { + var d net.Dialer + conn, err := d.DialContext(ctx, "unix", b.SocketPath()) + if err != nil { + return nil, fmt.Errorf("captured: %w", err) + } + defer conn.Close() + enc := json.NewEncoder(conn) + dec := json.NewDecoder(conn) + + log.Printf("captured: sending list-displays") + if err := enc.Encode(map[string]string{"type": "list-displays"}); err != nil { + return nil, err + } + var resp capturedDisplayResp + if err := dec.Decode(&resp); err != nil { + log.Printf("captured: list-displays decode error: %v", err) + return nil, err + } + if resp.Error != "" { + log.Printf("captured: list-displays error: %s", resp.Error) + return nil, errors.New(resp.Error) + } + log.Printf("captured: got %d display(s)", len(resp.Displays)) + out := make([]Display, len(resp.Displays)) + for i, d := range resp.Displays { + log.Printf("captured: display[%d] id=%d %dx%d @ (x=%d,y=%d) %.2fhz", i, d.ID, d.Width, d.Height, d.X, d.Y, d.RefreshRate) + out[i] = Display{ID: d.ID, Width: d.Width, Height: d.Height, X: d.X, Y: d.Y, RefreshRate: d.RefreshRate} + } + return out, nil +} + +// capturedStream implements Stream for the captured+ffmpeg pipeline. +type capturedStream struct { + req StartRequest + width int + height int + ctrl net.Conn + media net.Conn + ffmpeg *exec.Cmd + stdin io.WriteCloser + chunks chan H264Chunk + cancel context.CancelFunc + closeMu sync.Mutex + closed bool +} + +func (s *capturedStream) Chunks() <-chan H264Chunk { return s.chunks } +func (s *capturedStream) Width() int { return s.width } +func (s *capturedStream) Height() int { return s.height } +func (s *capturedStream) FPS() int { return s.req.FPS } +func (s *capturedStream) Codec() string { return s.req.Codec } + +func (s *capturedStream) Close() error { + s.closeMu.Lock() + defer s.closeMu.Unlock() + if s.closed { + return nil + } + s.closed = true + s.cancel() + s.stdin.Close() + s.ffmpeg.Wait() + s.media.Close() + json.NewEncoder(s.ctrl).Encode(map[string]string{"type": "stop-stream"}) + s.ctrl.Close() + return nil +} + +// StartStream dials start-stream on the control socket, opens the media +// socket, spawns ffmpeg and returns a chunk stream. +func (b *CapturedBackend) StartStream(ctx context.Context, req StartRequest) (Stream, error) { + if req.FPS <= 0 { + req.FPS = 60 + } + var d net.Dialer + ctrl, err := d.DialContext(ctx, "unix", b.SocketPath()) + if err != nil { + return nil, fmt.Errorf("captured control: %w", err) + } + enc := json.NewEncoder(ctrl) + dec := json.NewDecoder(ctrl) + + log.Printf("captured: sending start-stream display=%d fps=%d", req.DisplayID, req.FPS) + if err := enc.Encode(map[string]any{ + "type": "start-stream", + "display_id": req.DisplayID, + "fps": req.FPS, + }); err != nil { + ctrl.Close() + return nil, fmt.Errorf("start-stream: %w", err) + } + var streamResp struct { + Type string `json:"type"` + Socket string `json:"socket"` + Format string `json:"format"` + Error string `json:"error,omitempty"` + } + if err := dec.Decode(&streamResp); err != nil { + ctrl.Close() + return nil, fmt.Errorf("start-stream response: %w", err) + } + if streamResp.Error != "" { + log.Printf("captured: start-stream error: %s", streamResp.Error) + ctrl.Close() + return nil, errors.New(streamResp.Error) + } + log.Printf("captured: start-stream ok socket=%s format=%s", streamResp.Socket, streamResp.Format) + + media, err := d.DialContext(ctx, "unix", streamResp.Socket) + if err != nil { + ctrl.Close() + return nil, fmt.Errorf("media socket: %w", err) + } + + var hdr [8]byte + if _, err := io.ReadFull(media, hdr[:]); err != nil { + ctrl.Close() + media.Close() + return nil, fmt.Errorf("first frame header: %w", err) + } + w := int(binary.BigEndian.Uint32(hdr[0:4])) + h := int(binary.BigEndian.Uint32(hdr[4:8])) + log.Printf("captured: first frame %dx%d", w, h) + firstFrame := make([]byte, w*h*4) + if _, err := io.ReadFull(media, firstFrame); err != nil { + ctrl.Close() + media.Close() + return nil, fmt.Errorf("first frame data: %w", err) + } + + encoder := probeEncoder() + args := buildFFmpegArgs(encoder, req, w, h) + + cmd := exec.Command("ffmpeg", args...) + stdin, err := cmd.StdinPipe() + if err != nil { + ctrl.Close() + media.Close() + return nil, fmt.Errorf("ffmpeg stdin: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + stdin.Close() + ctrl.Close() + media.Close() + return nil, fmt.Errorf("ffmpeg stdout: %w", err) + } + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + stdin.Close() + ctrl.Close() + media.Close() + return nil, fmt.Errorf("ffmpeg start: %w", err) + } + if _, err := stdin.Write(firstFrame); err != nil { + stdin.Close() + cmd.Wait() + ctrl.Close() + media.Close() + return nil, fmt.Errorf("write first frame: %w", err) + } + log.Printf("encoder: %s %dx%d @ %dfps", encoder, w, h, req.FPS) + + ctx2, cancel := context.WithCancel(context.Background()) + s := &capturedStream{ + req: req, + width: w, + height: h, + ctrl: ctrl, + media: media, + ffmpeg: cmd, + stdin: stdin, + chunks: make(chan H264Chunk, 128), + cancel: cancel, + } + + // BGRA frames -> ffmpeg stdin. + go func() { + var buf [8]byte + for { + if _, err := io.ReadFull(media, buf[:]); err != nil { + break + } + fw := int(binary.BigEndian.Uint32(buf[0:4])) + fh := int(binary.BigEndian.Uint32(buf[4:8])) + frame := make([]byte, fw*fh*4) + if _, err := io.ReadFull(media, frame); err != nil { + break + } + if ctx2.Err() != nil { + break + } + if _, err := stdin.Write(frame); err != nil { + break + } + } + stdin.Close() + }() + + // ffmpeg stdout -> chunks channel (Annex B already chunked by muxer). + go func() { + defer close(s.chunks) + r := bufio.NewReaderSize(stdout, 1<<16) + buf := make([]byte, 65536) + for ctx2.Err() == nil { + n, err := r.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + select { + case s.chunks <- H264Chunk{Data: chunk}: + case <-ctx2.Done(): + return + } + } + if err != nil { + return + } + } + }() + + return s, nil +} + +func buildFFmpegArgs(encoder string, req StartRequest, w, h int) []string { + args := []string{ + "-y", + "-f", "rawvideo", + "-pix_fmt", "bgra", + "-s", fmt.Sprintf("%dx%d", w, h), + "-r", strconv.Itoa(req.FPS), + "-i", "pipe:0", + "-c:v", encoder, + "-pix_fmt", "yuv420p", + } + switch encoder { + case "h264_videotoolbox", "hevc_videotoolbox": + args = append(args, "-realtime", "true") + case "h264_nvenc": + args = append(args, "-preset", "p1", "-tune", "ull") + case "h264_amf": + args = append(args, "-usage", "ultralowlatency", "-quality", "speed") + case "h264_vaapi": + args = append(args, "-compression_level", "1") + case "h264_qsv": + args = append(args, "-preset", "veryfast") + } + switch req.Codec { + case "hevc": + args = append(args, "-f", "hevc") + case "av1": + args = append(args, "-f", "av1") + case "vp9": + args = append(args, "-f", "ivf") + default: + args = append(args, "-f", "h264") + } + if req.Bitrate > 0 { + args = append(args, "-b:v", strconv.Itoa(req.Bitrate)) + } + args = append(args, "-") + return args +} + +func probeEncoder() string { + out, err := exec.Command("ffmpeg", "-encoders").Output() + if err != nil { + return "libx264" + } + s := string(out) + prefs := []string{"h264_videotoolbox", "hevc_videotoolbox", "h264_nvenc", "h264_amf", "h264_qsv", "h264_vaapi"} + for _, name := range prefs { + if strings.Contains(s, name) { + return name + } + } + return "libx264" +} diff --git a/src/backend/rdp.go b/src/backend/rdp.go new file mode 100644 index 0000000..04aacf2 --- /dev/null +++ b/src/backend/rdp.go @@ -0,0 +1,44 @@ +package backend + +// Backend "rdp": connects to an RDP server (MS-RDPBCGR), negotiates a +// graphics channel and re-encodes updates to H264. Stub: TCP reachability +// only. Full MS-RDPBCGR handshake is large; eval freerdp/librdp bindings +// before hand-rolling. + +import ( + "context" + "fmt" + "log" +) + +func init() { Register(&RDPBackend{}) } + +const rdpDefaultPort = 3389 + +// RDPBackend captures a remote RDP session. +type RDPBackend struct { + // Addr is host[:port] of the RDP server; default port 3389. + Addr string +} + +func (b *RDPBackend) Name() string { return "rdp" } + +// ListDisplays verifies TCP reachability on the RDP port. +func (b *RDPBackend) ListDisplays(ctx context.Context) ([]Display, error) { + if b.Addr == "" { + return nil, fmt.Errorf("rdp: no --rdp address configured") + } + if err := tcpProbe(ctx, b.Addr, rdpDefaultPort); err != nil { + return nil, fmt.Errorf("rdp: %w", err) + } + log.Printf("rdp: %s reachable; MS-RDPBCGR handshake pending implementation", b.Addr) + return nil, ErrNotImplemented +} + +// StartStream is not implemented yet. +func (b *RDPBackend) StartStream(ctx context.Context, req StartRequest) (Stream, error) { + if _, err := b.ListDisplays(ctx); err != nil { + return nil, err + } + return nil, fmt.Errorf("rdp: %w (MS-RDPBCGR not yet implemented)", ErrNotImplemented) +} diff --git a/src/backend/sunshine.go b/src/backend/sunshine.go new file mode 100644 index 0000000..aebfe80 --- /dev/null +++ b/src/backend/sunshine.go @@ -0,0 +1,110 @@ +package backend + +// Backend "sunshine": connects to a Sunshine host (Moonlight protocol) and +// passes through its H.264 Annex B directly — no local ffmpeg. +// +// Protocol study reference: .tmp/eval/moonlight-web-stream (Rust) and its +// moonlight-common fork. The Moonlight handshake is: +// +// 1. HTTPS to :47989 — /serverinfo gives host info + RTSP session count. +// 2. HTTP /launch (or /resume) with query args: uniqueid, uuid, +// rikeyid, rikey (symmetric AES key, hex), remoteAudioEnabled, +// supportedDisplayModes, mode WxHxFPS, sops, etc. Response is XML +// 200.... +// 3. RTSP to :48010, port mode 53000-ish: OPTIONS -> DESCRIBE -> +// SETUP (aggregated or per-track, client keeps rikey for SRTP AES-GCM) +// -> PLAY. Video arrives over UDP with enroll/feedback channels; H264 +// payload needs NAL reassembly from Moonlight's framed packets into +// Annex B before handoff to us. +// 4. Control channel on :47999 (TCP) carries input events + keepalives. +// +// v1 scope here: reachability probe of 47989 and an explicit +// ErrNotImplemented until the RTSP/SRTP handshake + NAL reassembly lands +// (Spike B follow-up). + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "strconv" + "strings" +) + +func init() { Register(&SunshineBackend{}) } + +// ErrNotImplemented is returned by backend stubs whose full protocol work +// has not landed yet. +var ErrNotImplemented = errors.New("backend not implemented yet") + +const ( + sunshineHTTPPort = 47989 // HTTPS serverinfo/launch/resume/cancel + sunshineRTSPPort = 48010 // RTSP control + sunshineCtrlPort = 47999 // TCP control/input + sunshineVideoPort = 47998 // UDP video + sunshineAudioPort = 48000 // UDP audio +) + +// SunshineBackend streams a Sunshine/Moonlight host's H264 passthrough. +type SunshineBackend struct { + // Addr is host[:port] of the Sunshine host; default port 47989. + Addr string +} + +func (b *SunshineBackend) Name() string { return "sunshine" } + +func (b *SunshineBackend) httpAddr() string { + host := b.Addr + if host == "" { + return "" + } + if _, _, err := net.SplitHostPort(host); err != nil { + return net.JoinHostPort(host, strconv.Itoa(sunshineHTTPPort)) + } + return host +} + +// ListDisplays probes the Sunshine HTTP endpoint. Full enumeration requires +// the /serverinfo handshake (see package comment); until then we only verify +// reachability so `--dry-run` diagnostics mean something. +func (b *SunshineBackend) ListDisplays(ctx context.Context) ([]Display, error) { + addr := b.httpAddr() + if addr == "" { + return nil, fmt.Errorf("sunshine: no --sunshine address configured") + } + var d net.Dialer + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("sunshine: probe %s: %w", addr, err) + } + conn.Close() + log.Printf("sunshine: %s reachable (HTTP %d); full display list pending RTSP handshake implementation", addr, sunshineHTTPPort) + return nil, ErrNotImplemented +} + +// StartStream performs the launch+RTSP+PLAY flow; see package comment. +func (b *SunshineBackend) StartStream(ctx context.Context, req StartRequest) (Stream, error) { + if _, err := b.ListDisplays(ctx); err != nil { + return nil, err + } + return nil, fmt.Errorf("sunshine: %w (launch/RTSP/NAL-reassembly not yet implemented)", ErrNotImplemented) +} + +// tcpProbe dials host:port once for reachability checks in stubs. +func tcpProbe(ctx context.Context, addr string, defPort int) error { + if addr == "" { + return fmt.Errorf("no address configured") + } + target := addr + if !strings.Contains(addr, ":") || strings.Count(addr, ":") == 0 { + target = net.JoinHostPort(addr, strconv.Itoa(defPort)) + } + var d net.Dialer + conn, err := d.DialContext(ctx, "tcp", target) + if err != nil { + return fmt.Errorf("probe %s: %w", target, err) + } + conn.Close() + return nil +} diff --git a/src/backend/vnc.go b/src/backend/vnc.go new file mode 100644 index 0000000..ce3657d --- /dev/null +++ b/src/backend/vnc.go @@ -0,0 +1,68 @@ +package backend + +// Backend "vnc": dials a VNC server (RFB protocol), requests continuous +// frame updates (SetContinuousUpdates / FramebufferUpdateRequest) and +// re-encodes frames to H264. Stub: reachability + RFB banner check only. + +import ( + "bufio" + "context" + "fmt" + "log" + "net" + "strconv" +) + +func init() { Register(&VNCBackend{}) } + +const vncDefaultPort = 5900 + +// VNCBackend captures a remote VNC display. +type VNCBackend struct { + // Addr is host[:port] of the VNC server; default port 5900. + Addr string +} + +func (b *VNCBackend) Name() string { return "vnc" } + +func (b *VNCBackend) addr() string { + if b.Addr == "" { + return "" + } + if _, _, err := net.SplitHostPort(b.Addr); err != nil { + return net.JoinHostPort(b.Addr, strconv.Itoa(vncDefaultPort)) + } + return b.Addr +} + +// ListDisplays connects and reads the RFB handshake banner ("RFB xxx.y"). +func (b *VNCBackend) ListDisplays(ctx context.Context) ([]Display, error) { + addr := b.addr() + if addr == "" { + return nil, fmt.Errorf("vnc: no --vnc address configured") + } + var d net.Dialer + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("vnc: %w", err) + } + defer conn.Close() + banner := make([]byte, 12) + n, err := bufio.NewReader(conn).Read(banner) + if err != nil { + log.Printf("vnc: banner read (%d bytes): %v", n, err) + } + if n >= 3 && string(banner[:3]) == "RFB" { + log.Printf("vnc: %s speaks %q; RFB capture pending implementation", addr, banner) + return nil, ErrNotImplemented + } + return nil, fmt.Errorf("vnc: not an RFB server at %s", addr) +} + +// StartStream is not implemented yet (needs RFB pixel decode + encode). +func (b *VNCBackend) StartStream(ctx context.Context, req StartRequest) (Stream, error) { + if _, err := b.ListDisplays(ctx); err != nil { + return nil, err + } + return nil, fmt.Errorf("vnc: %w (RFB frame polling not yet implemented)", ErrNotImplemented) +} diff --git a/src/captured.go b/src/captured.go deleted file mode 100644 index 4849bad..0000000 --- a/src/captured.go +++ /dev/null @@ -1,65 +0,0 @@ -package main - -import ( - "encoding/json" - "errors" - "fmt" - "log" - "net" - "os" -) - -func capturedSocket() string { - if v := os.Getenv("CAPTURED_SOCKET"); v != "" { - return v - } - return "/tmp/captured.socket" -} - -func listDisplays() ([]map[string]any, error) { - conn, err := net.Dial("unix", capturedSocket()) - if err != nil { - return nil, fmt.Errorf("captured: %w", err) - } - defer conn.Close() - enc := json.NewEncoder(conn) - dec := json.NewDecoder(conn) - - log.Printf("captured: sending list-displays") - if err := enc.Encode(map[string]string{"type": "list-displays"}); err != nil { - return nil, err - } - var resp struct { - Displays []struct { - ID uint32 `json:"id"` - Width int `json:"width"` - Height int `json:"height"` - X int `json:"x"` - Y int `json:"y"` - RefreshRate float64 `json:"refresh_rate"` - } `json:"displays"` - Error string `json:"error,omitempty"` - } - if err := dec.Decode(&resp); err != nil { - log.Printf("captured: list-displays decode error: %v", err) - return nil, err - } - if resp.Error != "" { - log.Printf("captured: list-displays error: %s", resp.Error) - return nil, errors.New(resp.Error) - } - log.Printf("captured: got %d display(s)", len(resp.Displays)) - out := make([]map[string]any, len(resp.Displays)) - for i, d := range resp.Displays { - log.Printf("captured: display[%d] id=%d %dx%d @ (x=%d,y=%d) %.2fhz", i, d.ID, d.Width, d.Height, d.X, d.Y, d.RefreshRate) - out[i] = map[string]any{ - "id": d.ID, - "width": d.Width, - "height": d.Height, - "x": d.X, - "y": d.Y, - "refresh_rate": d.RefreshRate, - } - } - return out, nil -} diff --git a/src/main.go b/src/main.go index 8fcd9a4..623dc96 100644 --- a/src/main.go +++ b/src/main.go @@ -2,8 +2,8 @@ package main import ( "context" - _ "embed" "crypto/tls" + _ "embed" "flag" "fmt" "log" @@ -11,25 +11,125 @@ import ( "net/http" "os" "os/signal" + "strings" + "time" + "github.com/okdaichi/webtransport-go" "github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" - "github.com/okdaichi/webtransport-go" - "github.com/qumo-dev/gomoqt/moqt" - "github.com/qumo-dev/gomoqt/transport" + + "distancedesktop/agent/src/backend" ) //go:embed web/index.html var webHTML string +// backendOpts holds parsed -- flag values. +type backendOpts struct { + captured string + sunshine string + vnc string + rdp string +} + +// parseKV parses "k=v,k2=v2" style option strings. +func parseKV(s string) map[string]string { + out := map[string]string{} + if s == "" { + return out + } + for _, part := range strings.Split(s, ",") { + kv := strings.SplitN(part, "=", 2) + if len(kv) == 2 { + out[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } + return out +} + +// configureBackends builds the concrete backends from flags. +func configureBackends(o backendOpts) { + reg := func(name, opts string, apply func(map[string]string)) { + if opts == "" { + return + } + b, err := backend.Get(name) + if err != nil { + log.Fatalf("%v", err) + } + switch t := b.(type) { + case *backend.SunshineBackend: + t.Addr = parseKV(opts)["addr"] + case *backend.VNCBackend: + t.Addr = parseKV(opts)["addr"] + case *backend.RDPBackend: + t.Addr = parseKV(opts)["addr"] + default: + apply(parseKV(opts)) + } + } + applyCaptured := func(kv map[string]string) {} // source=/device= handled inside captured pipeline + reg("sunshine", o.sunshine, applyCaptured) + reg("vnc", o.vnc, applyCaptured) + reg("rdp", o.rdp, applyCaptured) + _ = o.captured // captured config (source/device) consumed by Spike B pipeline +} + +// selectBackend resolves the requested backend, probing candidates in auto order. +func selectBackend(name string, dryRun bool) backend.Backend { + if name == "" || name == "auto" { + for _, cand := range backend.Candidates() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + _, err := cand.Backend.ListDisplays(ctx) + cancel() + if err == nil { + log.Printf("auto: selected backend %q", cand.Name) + return cand.Backend + } + log.Printf("auto: %s unavailable: %v", cand.Name, err) + } + log.Fatalf("auto: no usable backend found") + } + b, err := backend.Get(name) + if err != nil { + log.Fatalf("%v", err) + } + return b +} + func main() { addr := flag.String("addr", ":52020", "WebTransport listen address (UDP)") webAddr := flag.String("web", ":52022", "Web UI listen address (TCP)") fingerprintOnly := flag.Bool("fingerprint", false, "print the SHA-256 fingerprint and exit") customCert := flag.String("cert", "", "TLS certificate path (ECDSA P-256 PEM)") customKey := flag.String("key", "", "TLS private key path (ECDSA P-256 PEM)") + + backendName := flag.String("backend", "auto", fmt.Sprintf("video backend: auto|%s", strings.Join(backend.Names(), "|"))) + var bopts backendOpts + flag.StringVar(&bopts.captured, "captured", "", `captured backend opts: "source=kms,device=/dev/dri/card1"`) + flag.StringVar(&bopts.sunshine, "sunshine", "", `sunshine backend opts: "addr=127.0.0.1:47989"`) + flag.StringVar(&bopts.vnc, "vnc", "", `vnc backend opts: "addr=10.10.1.6:5901"`) + flag.StringVar(&bopts.rdp, "rdp", "", `rdp backend opts: "addr=10.10.1.6:3389"`) + dryRun := flag.Bool("dry-run", false, "list displays via the selected backend and exit") flag.Parse() + configureBackends(bopts) + sel := selectBackend(*backendName, *dryRun) + + if *dryRun { + displays, err := sel.ListDisplays(context.Background()) + if err != nil { + log.Fatalf("dry-run: %v", err) + } + for _, d := range displays { + fmt.Printf("display id=%d %dx%d @ (%d,%d) %.2fhz\n", d.ID, d.Width, d.Height, d.X, d.Y, d.RefreshRate) + } + return + } + + // Remember the selection for the session handlers. + activeBackend = sel + var tlsConfig *tls.Config var cm *certManager @@ -56,8 +156,8 @@ func main() { } wtUpgrader := &webtransport.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, - ApplicationProtocols: []string{"moq-lite-04"}, + CheckOrigin: func(r *http.Request) bool { return true }, + ApplicationProtocols: []string{"moq-lite-04"}, // kept for legacy client compat } wtServer := &webtransport.Server{ @@ -71,26 +171,6 @@ func main() { } wtMux := http.NewServeMux() - moqMux := moqt.NewTrackMux(0) - var moqBroadcastCtx context.Context - moqBroadcastCtx, moqBroadcastCancel = context.WithCancel(context.Background()) - - wtMux.Handle("/moq", &moqt.WebTransportHandler{ - TrackMux: moqMux, - Config: &moqt.Config{}, - UpgradeFunc: func(w http.ResponseWriter, r *http.Request) (transport.WebTransportSession, error) { - s, err := wtUpgrader.Upgrade(w, r) - if err != nil { - return nil, err - } - return wrapWTSession(s), nil - }, - Handler: moqt.HandleFunc(func(sess *moqt.Session) { - log.Printf("moq session from %s", sess.RemoteAddr()) - <-sess.Context().Done() - log.Printf("moq session closed: %s", sess.RemoteAddr()) - }), - }) wtMux.HandleFunc("/wt", func(w http.ResponseWriter, r *http.Request) { remote := r.RemoteAddr @@ -104,54 +184,6 @@ func main() { log.Printf("WT upgrade succeeded from %s", remote) go handleSession(s, cm) }) - moqMux.PublishFunc(moqBroadcastCtx, "/video", func(tw *moqt.TrackWriter) { - name := tw.TrackName - log.Printf("moq subscribe broadcast=/video track=%s", name) - - if name == "catalog.json" { - stateMu.Lock() - w, h := 0, 0 - if state != nil { - w, h = state.width, state.height - } - stateMu.Unlock() - catalog := fmt.Sprintf(`{"version":1,"video":{"renditions":{"video":{"codec":"h264","bitrate":0,"width":%d,"height":%d,"name":"video"}}}}`, w, h) - g, err := tw.OpenGroup() - if err != nil { - log.Printf("moq catalog: open group: %v", err) - return - } - f := moqt.NewFrame(len(catalog)) - f.Write([]byte(catalog)) - if err := g.WriteFrame(f); err != nil { - log.Printf("moq catalog: write frame: %v", err) - g.CancelWrite(0) - return - } - g.Close() - log.Printf("moq catalog served") - return - } - - stateMu.Lock() - if state != nil { - state.moqTrackMu.Lock() - state.moqTracks[tw] = struct{}{} - log.Printf("moq video subscriber added, now %d", len(state.moqTracks)) - state.moqTrackMu.Unlock() - } - stateMu.Unlock() - defer func() { - stateMu.Lock() - if state != nil { - state.moqTrackMu.Lock() - delete(state.moqTracks, tw) - state.moqTrackMu.Unlock() - } - stateMu.Unlock() - }() - <-tw.Context().Done() - }) wtServer.H3.Handler = wtMux @@ -189,4 +221,3 @@ func main() { stateMu.Unlock() wtServer.Close() } - diff --git a/src/moq_adapter.go b/src/moq_adapter.go deleted file mode 100644 index 661107b..0000000 --- a/src/moq_adapter.go +++ /dev/null @@ -1,104 +0,0 @@ -package main - -import ( - "context" - "crypto/tls" - "net" - "time" - - "github.com/okdaichi/webtransport-go" - "github.com/qumo-dev/gomoqt/transport" -) - -type wtSessionAdapter struct { - sess *webtransport.Session -} - -func wrapWTSession(s *webtransport.Session) transport.WebTransportSession { - return &wtSessionAdapter{sess: s} -} - -func (a *wtSessionAdapter) AcceptStream(ctx context.Context) (transport.Stream, error) { - s, err := a.sess.AcceptStream(ctx) - return &wtStreamAdapter{stream: s}, err -} - -func (a *wtSessionAdapter) AcceptUniStream(ctx context.Context) (transport.ReceiveStream, error) { - s, err := a.sess.AcceptUniStream(ctx) - return &wtReceiveStreamAdapter{stream: s}, err -} - -func (a *wtSessionAdapter) CloseWithError(code transport.ConnErrorCode, msg string) error { - return a.sess.CloseWithError(webtransport.SessionErrorCode(code), msg) -} - -func (a *wtSessionAdapter) Context() context.Context { - return a.sess.Context() -} - -func (a *wtSessionAdapter) LocalAddr() net.Addr { - return a.sess.LocalAddr() -} - -func (a *wtSessionAdapter) OpenStream() (transport.Stream, error) { - s, err := a.sess.OpenStream() - return &wtStreamAdapter{stream: s}, err -} - -func (a *wtSessionAdapter) OpenUniStream() (transport.SendStream, error) { - s, err := a.sess.OpenUniStream() - return &wtSendStreamAdapter{stream: s}, err -} - -func (a *wtSessionAdapter) RemoteAddr() net.Addr { - return a.sess.RemoteAddr() -} - -func (a *wtSessionAdapter) TLS() *tls.ConnectionState { - state := a.sess.SessionState() - return &state.ConnectionState.TLS -} - -func (a *wtSessionAdapter) Subprotocol() string { - return a.sess.SessionState().ApplicationProtocol -} - -type wtStreamAdapter struct { - stream *webtransport.Stream -} - -func (a *wtStreamAdapter) Read(b []byte) (int, error) { return a.stream.Read(b) } -func (a *wtStreamAdapter) Write(b []byte) (int, error) { return a.stream.Write(b) } -func (a *wtStreamAdapter) Close() error { return a.stream.Close() } -func (a *wtStreamAdapter) Context() context.Context { return a.stream.Context() } -func (a *wtStreamAdapter) SetDeadline(t time.Time) error { return a.stream.SetDeadline(t) } -func (a *wtStreamAdapter) SetReadDeadline(t time.Time) error { return a.stream.SetReadDeadline(t) } -func (a *wtStreamAdapter) SetWriteDeadline(t time.Time) error { return a.stream.SetWriteDeadline(t) } -func (a *wtStreamAdapter) CancelRead(code transport.StreamErrorCode) { - a.stream.CancelRead(webtransport.StreamErrorCode(code)) -} -func (a *wtStreamAdapter) CancelWrite(code transport.StreamErrorCode) { - a.stream.CancelWrite(webtransport.StreamErrorCode(code)) -} - -type wtSendStreamAdapter struct { - stream *webtransport.SendStream -} - -func (a *wtSendStreamAdapter) Write(b []byte) (int, error) { return a.stream.Write(b) } -func (a *wtSendStreamAdapter) Close() error { return a.stream.Close() } -func (a *wtSendStreamAdapter) Context() context.Context { return a.stream.Context() } -func (a *wtSendStreamAdapter) SetWriteDeadline(t time.Time) error { return a.stream.SetWriteDeadline(t) } -func (a *wtSendStreamAdapter) CancelWrite(code transport.StreamErrorCode) { - a.stream.CancelWrite(webtransport.StreamErrorCode(code)) -} - -type wtReceiveStreamAdapter struct { - stream *webtransport.ReceiveStream -} - -func (a *wtReceiveStreamAdapter) Read(b []byte) (int, error) { return a.stream.Read(b) } -func (a *wtReceiveStreamAdapter) SetReadDeadline(t time.Time) error { return a.stream.SetReadDeadline(t) } -func (a *wtReceiveStreamAdapter) CancelRead(code transport.StreamErrorCode) { - a.stream.CancelRead(webtransport.StreamErrorCode(code)) -} diff --git a/src/session.go b/src/session.go index 73075b3..6b15512 100644 --- a/src/session.go +++ b/src/session.go @@ -140,7 +140,10 @@ func handleSession(wtSess *webtransport.Session, cm *certManager) { continue } stateMu.Lock() - w, h := state.width, state.height + var w, h int + if state != nil && state.stream != nil { + w, h = state.stream.Width(), state.stream.Height() + } stateMu.Unlock() log.Printf("session %s: stream started %dx%d", remote, w, h) sendControlMsg(sub, map[string]any{ diff --git a/src/stream.go b/src/stream.go index b49b252..03e1e3e 100644 --- a/src/stream.go +++ b/src/stream.go @@ -2,22 +2,15 @@ package main import ( "context" - "encoding/binary" - "encoding/json" - "errors" "fmt" - "io" "log" - "net" - "os" - "os/exec" - "strconv" - "strings" "time" - "github.com/qumo-dev/gomoqt/moqt" + "distancedesktop/agent/src/backend" ) +// startStream begins a video stream via the active backend and attaches the +// caller. Late-joiners attach to an existing stream. func startStream(displayID, fps int, codec string, bitrate int, caller *subscriber) error { stateMu.Lock() defer stateMu.Unlock() @@ -36,139 +29,31 @@ func startStream(displayID, fps int, codec string, bitrate int, caller *subscrib return nil } - log.Printf("startStream: dialing captured socket %s", capturedSocket()) - ctrl, err := net.Dial("unix", capturedSocket()) - if err != nil { - return fmt.Errorf("captured control: %w", err) - } - - enc := json.NewEncoder(ctrl) - dec := json.NewDecoder(ctrl) - - log.Printf("captured: sending start-stream display=%d fps=%d", displayID, fps) - if err := enc.Encode(map[string]any{ - "type": "start-stream", - "display_id": uint32(displayID), - "fps": fps, - }); err != nil { - ctrl.Close() - return fmt.Errorf("start-stream: %w", err) - } - var streamResp struct { - Type string `json:"type"` - Socket string `json:"socket"` - Format string `json:"format"` - Error string `json:"error,omitempty"` - } - if err := dec.Decode(&streamResp); err != nil { - ctrl.Close() - return fmt.Errorf("start-stream response: %w", err) - } - if streamResp.Error != "" { - log.Printf("captured: start-stream error: %s", streamResp.Error) - ctrl.Close() - return errors.New(streamResp.Error) - } - log.Printf("captured: start-stream ok socket=%s format=%s", streamResp.Socket, streamResp.Format) - - media, err := net.Dial("unix", streamResp.Socket) - if err != nil { - ctrl.Close() - return fmt.Errorf("media socket: %w", err) - } - - var hdr [8]byte - if _, err := io.ReadFull(media, hdr[:]); err != nil { - ctrl.Close() - media.Close() - return fmt.Errorf("first frame header: %w", err) - } - w := int(binary.BigEndian.Uint32(hdr[0:4])) - h := int(binary.BigEndian.Uint32(hdr[4:8])) - log.Printf("captured: first frame %dx%d", w, h) - firstFrame := make([]byte, w*h*4) - if _, err := io.ReadFull(media, firstFrame); err != nil { - ctrl.Close() - media.Close() - return fmt.Errorf("first frame data: %w", err) - } - - encoder := probeEncoder() - - args := []string{ - "-y", - "-f", "rawvideo", - "-pix_fmt", "bgra", - "-s", fmt.Sprintf("%dx%d", w, h), - "-r", strconv.Itoa(fps), - "-i", "pipe:0", - "-c:v", encoder, - "-pix_fmt", "yuv420p", - } - - switch encoder { - case "h264_videotoolbox", "hevc_videotoolbox": - args = append(args, "-realtime", "true") - case "h264_nvenc": - args = append(args, "-preset", "p1", "-tune", "ull") - case "h264_amf": - args = append(args, "-usage", "ultralowlatency", "-quality", "speed") - case "h264_vaapi": - args = append(args, "-compression_level", "1") - case "h264_qsv": - args = append(args, "-preset", "veryfast") - } - - switch codec { - case "hevc": - args = append(args, "-f", "hevc") - case "av1": - args = append(args, "-f", "av1") - case "vp9": - args = append(args, "-f", "ivf") - default: - args = append(args, "-f", "h264") + b := activeBackend + if b == nil { + var err error + b, err = backend.Get("captured") + if err != nil { + return err + } + activeBackend = b } - if bitrate > 0 { - args = append(args, "-b:v", strconv.Itoa(bitrate)) + req := backend.StartRequest{ + DisplayID: uint32(displayID), + FPS: fps, + Codec: codec, + Bitrate: bitrate, } - - args = append(args, "-") - - cmd := exec.Command("ffmpeg", args...) - stdin, err := cmd.StdinPipe() + stream, err := b.StartStream(context.Background(), req) if err != nil { - ctrl.Close() - media.Close() - return fmt.Errorf("ffmpeg stdin: %w", err) - } - stdout, err := cmd.StdoutPipe() - if err != nil { - stdin.Close() - ctrl.Close() - media.Close() - return fmt.Errorf("ffmpeg stdout: %w", err) - } - cmd.Stderr = os.Stderr - - if err := cmd.Start(); err != nil { - stdin.Close() - ctrl.Close() - media.Close() - return fmt.Errorf("ffmpeg start: %w", err) + return fmt.Errorf("%s start-stream: %w", b.Name(), err) } - - stdin.Write(firstFrame) - - log.Printf("encoder: %s %dx%d @ %dfps", encoder, w, h, fps) + log.Printf("backend %s: stream started %dx%d @ %dfps", b.Name(), stream.Width(), stream.Height(), stream.FPS()) videoStream, err := caller.sess.OpenUniStream() if err != nil { - stdin.Close() - cmd.Wait() - ctrl.Close() - media.Close() + stream.Close() return fmt.Errorf("video stream: %w", err) } caller.video = videoStream @@ -176,65 +61,26 @@ func startStream(displayID, fps int, codec string, bitrate int, caller *subscrib pubCtx, pubCancel := context.WithCancel(context.Background()) state = &streamState{ - displayID: displayID, - width: w, - height: h, - fps: fps, - capturedCtrl: ctrl, - capturedMedia: media, - ffmpeg: cmd, - ffmpegIn: stdin, - ffmpegOut: stdout, - subscribers: make(map[*subscriber]struct{}), - moqTracks: make(map[*moqt.TrackWriter]struct{}), - stopPub: pubCancel, - owner: caller, + stream: stream, + subscribers: make(map[*subscriber]struct{}), + stopPub: pubCancel, + owner: caller, } state.subscribers[caller] = struct{}{} - go func() { - var buf [8]byte - for { - if _, err := io.ReadFull(media, buf[:]); err != nil { - break - } - fw := int(binary.BigEndian.Uint32(buf[0:4])) - fh := int(binary.BigEndian.Uint32(buf[4:8])) - frame := make([]byte, fw*fh*4) - if _, err := io.ReadFull(media, frame); err != nil { - break - } - if _, err := stdin.Write(frame); err != nil { - break - } - } - stdin.Close() - }() - - go publishStream(pubCtx, stdout) + go publishStream(pubCtx, state) return nil } -func publishStream(ctx context.Context, r io.ReadCloser) { - buf := make([]byte, 65536) - for { - n, err := r.Read(buf) - if err != nil { - return - } +// publishStream fans backend chunks out to all subscribers. +func publishStream(ctx context.Context, ss *streamState) { + for chunk := range ss.stream.Chunks() { if ctx.Err() != nil { return } - stateMu.Lock() - ss := state - stateMu.Unlock() - if ss == nil { - continue - } - ss.subMu.Lock() if ss.subscribers == nil { ss.subMu.Unlock() @@ -245,84 +91,25 @@ func publishStream(ctx context.Context, r io.ReadCloser) { continue } sub.video.SetWriteDeadline(time.Now().Add(100 * time.Millisecond)) - if _, err := sub.video.Write(buf[:n]); err != nil { + if _, err := sub.video.Write(chunk.Data); err != nil { sub.video.Close() delete(ss.subscribers, sub) } } ss.subMu.Unlock() - - ss.moqTrackMu.Lock() - for tw := range ss.moqTracks { - if ctx.Err() != nil { - ss.moqTrackMu.Unlock() - return - } - g, err := tw.OpenGroup() - if err != nil { - log.Printf("moq write: open group error: %v, removing track", err) - delete(ss.moqTracks, tw) - continue - } - f := moqt.NewFrame(n) - f.Write(buf[:n]) - if err := g.WriteFrame(f); err != nil { - log.Printf("moq write: write frame error: %v, removing track", err) - g.CancelWrite(0) - delete(ss.moqTracks, tw) - continue - } - g.Close() - } - moqCount := len(ss.moqTracks) - ss.moqTrackMu.Unlock() - if moqCount > 0 { - log.Printf("moq wrote %d bytes to %d track(s)", n, moqCount) - } - } -} - -func probeEncoder() string { - out, err := exec.Command("ffmpeg", "-encoders").Output() - if err != nil { - return "libx264" - } - s := string(out) - prefs := []string{"h264_videotoolbox", "hevc_videotoolbox", "h264_nvenc", "h264_amf", "h264_qsv", "h264_vaapi"} - for _, name := range prefs { - if strings.Contains(s, name) { - return name - } } - return "libx264" } func teardown() { if state == nil { return } - log.Printf("teardown: stopping stream (display=%d %dx%d)", state.displayID, state.width, state.height) - if state.stopPub != nil { - state.stopPub() - } - if state.ffmpegIn != nil { - state.ffmpegIn.Close() - } - if state.ffmpeg != nil { - state.ffmpeg.Wait() - } - if state.capturedMedia != nil { - state.capturedMedia.Close() - } - if state.capturedCtrl != nil { - log.Printf("captured: sending stop-stream") - json.NewEncoder(state.capturedCtrl).Encode(map[string]string{"type": "stop-stream"}) - state.capturedCtrl.Close() - } + ss := state + log.Printf("teardown: stopping stream") - state.subMu.Lock() - subCount := len(state.subscribers) - for sub := range state.subscribers { + ss.subMu.Lock() + subCount := len(ss.subscribers) + for sub := range ss.subscribers { sendControlMsg(sub, map[string]string{"type": "stream-ended"}) if sub.video != nil { sub.video.Close() @@ -330,19 +117,17 @@ func teardown() { sub.sess.CloseWithError(0, "stream ended") } log.Printf("teardown: closed %d subscriber(s)", subCount) - state.subscribers = nil - state.subMu.Unlock() + ss.subscribers = nil + ss.subMu.Unlock() - state.moqTrackMu.Lock() - trackCount := len(state.moqTracks) - for tw := range state.moqTracks { - tw.Close() + if ss.stopPub != nil { + ss.stopPub() + } + if ss.stream != nil { + if err := ss.stream.Close(); err != nil { + log.Printf("teardown: stream close: %v", err) + } } - state.moqTracks = nil - state.moqTrackMu.Unlock() - log.Printf("teardown: closed %d moq track(s)", trackCount) - - moqBroadcastCancel() state = nil } diff --git a/src/types.go b/src/types.go index 60d32d5..10716e3 100644 --- a/src/types.go +++ b/src/types.go @@ -2,15 +2,14 @@ package main import ( "context" - "io" - "net" - "os/exec" "sync" - "github.com/qumo-dev/gomoqt/moqt" "github.com/okdaichi/webtransport-go" + + "distancedesktop/agent/src/backend" ) +// subscriber wraps one WebTransport session's control + video streams. type subscriber struct { sess *webtransport.Session ctrl *webtransport.Stream @@ -18,30 +17,33 @@ type subscriber struct { video *webtransport.SendStream } +// streamState tracks the one active video stream. type streamState struct { - displayID int - width int - height int - fps int - - capturedCtrl net.Conn - capturedMedia net.Conn - - ffmpeg *exec.Cmd - ffmpegIn io.WriteCloser - ffmpegOut io.ReadCloser + stream backend.Stream subscribers map[*subscriber]struct{} subMu sync.Mutex - moqTracks map[*moqt.TrackWriter]struct{} - moqTrackMu sync.Mutex stopPub context.CancelFunc owner *subscriber } var ( - state *streamState - stateMu sync.Mutex - moqBroadcastCancel context.CancelFunc + state *streamState + stateMu sync.Mutex + + // activeBackend is resolved at startup from --backend/-- flags. + activeBackend backend.Backend ) + +// listDisplays queries the active backend (kept as a helper for session.go). +func listDisplays() ([]backend.Display, error) { + if activeBackend == nil { + b, err := backend.Get("captured") + if err != nil { + return nil, err + } + activeBackend = b + } + return activeBackend.ListDisplays(context.Background()) +} diff --git a/src/web/index.html b/src/web/index.html index a902afc..cb0f3e0 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -1,42 +1,27 @@ - - - -Distance Desktop - - - -
-

Distance Desktop

-
Server Fingerprint
-
loading...
-
QR
-
loading IPs...
-
Copy this fingerprint into the Distance Desktop client
to establish a secure connection.
-
- - + + + + + Distance Desktop + + +
+ +
+ + + + + +
+ + diff --git a/src/web/package.json b/src/web/package.json new file mode 100644 index 0000000..b90c543 --- /dev/null +++ b/src/web/package.json @@ -0,0 +1,16 @@ +{ + "name": "distance-web", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Distance Desktop self-hosted web viewer (vanilla TS + WebCodecs + WebTransport)", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "^5.5.4", + "vite": "^5.4.8" + } +} diff --git a/src/web/style.css b/src/web/style.css new file mode 100644 index 0000000..78724d6 --- /dev/null +++ b/src/web/style.css @@ -0,0 +1,442 @@ +:root { + --bg: #0c0d10; + --bg-elev: #15171c; + --bg-elev-2: #1d2026; + --border: #2a2e37; + --border-strong: #3a404c; + --text: #e7e9ee; + --text-dim: #9aa1ad; + --text-faint: #6b7280; + --accent: #5b8cff; + --accent-2: #3df0a8; + --danger: #ff5c6c; + --warn: #ffcf5c; + --mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, monospace; + --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --radius: 12px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + font-family: var(--sans); + background: radial-gradient(1200px 800px at 70% -10%, #161a22 0%, var(--bg) 60%); + color: var(--text); + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +#app { + position: fixed; + inset: 0; +} + +.screen { + position: absolute; + inset: 0; +} + +.hidden { + display: none !important; +} + +/* ---------- Connect screen ---------- */ +#connect { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + overflow: auto; +} + +.connect-card { + width: min(560px, 100%); + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45); +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 4px; +} + +.brand .dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent-2); + box-shadow: 0 0 12px var(--accent-2); +} + +.brand h1 { + font-size: 1.25rem; + font-weight: 650; + letter-spacing: 0.2px; +} + +.subtitle { + color: var(--text-dim); + font-size: 0.85rem; + margin-bottom: 22px; +} + +.field { + margin-bottom: 16px; +} + +.field > label { + display: block; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); + margin-bottom: 6px; +} + +input[type="text"], +input[type="number"], +textarea { + width: 100%; + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: 9px; + color: var(--text); + font-family: var(--mono); + font-size: 0.85rem; + padding: 10px 12px; + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; +} + +input:focus, +textarea:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(91, 140, 255, 0.18); +} + +textarea { + resize: vertical; + min-height: 64px; + word-break: break-all; + line-height: 1.5; +} + +.row { + display: flex; + gap: 12px; +} + +.row > .field { + flex: 1; +} + +.btn { + appearance: none; + border: 1px solid var(--border-strong); + background: var(--bg-elev-2); + color: var(--text); + font-size: 0.85rem; + font-weight: 550; + padding: 10px 14px; + border-radius: 9px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, transform 0.05s; +} + +.btn:hover { + border-color: var(--accent); +} + +.btn:active { + transform: translateY(1px); +} + +.btn.primary { + background: linear-gradient(180deg, #5b8cff, #3f6fe0); + border-color: #3f6fe0; + color: #fff; +} + +.btn.primary:hover { + filter: brightness(1.06); +} + +.btn.ghost { + background: transparent; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.actions { + display: flex; + gap: 10px; + margin-top: 6px; +} + +.actions .btn.primary { + flex: 1; +} + +.recent { + margin-top: 22px; + border-top: 1px solid var(--border); + padding-top: 16px; +} + +.recent h2 { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-faint); + margin-bottom: 10px; +} + +.recent-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.recent-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: 9px; + cursor: pointer; +} + +.recent-item:hover { + border-color: var(--accent); +} + +.recent-item .meta { + flex: 1; + min-width: 0; +} + +.recent-item .host { + font-family: var(--mono); + font-size: 0.82rem; +} + +.recent-item .fp { + font-family: var(--mono); + font-size: 0.68rem; + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recent-item .del { + color: var(--text-faint); + border: none; + background: none; + cursor: pointer; + font-size: 1rem; + padding: 2px 6px; +} + +.recent-item .del:hover { + color: var(--danger); +} + +.displays { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; + margin-top: 14px; +} + +.display-tile { + background: var(--bg-elev-2); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px; + cursor: pointer; + text-align: left; +} + +.display-tile:hover { + border-color: var(--accent); +} + +.display-tile .name { + font-weight: 600; + font-size: 0.9rem; +} + +.display-tile .res { + color: var(--text-dim); + font-size: 0.78rem; + margin-top: 4px; + font-family: var(--mono); +} + +/* ---------- Viewer ---------- */ +#viewer { + background: #000; +} + +#stage { + width: 100%; + height: 100%; + display: block; + object-fit: contain; + cursor: none; + background: #000; +} + +#stage:not(.locked) { + cursor: default; +} + +.viewer-hint { + position: absolute; + left: 50%; + bottom: 18px; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.55); + border: 1px solid var(--border); + color: var(--text-dim); + font-size: 0.76rem; + padding: 7px 12px; + border-radius: 999px; + backdrop-filter: blur(6px); + pointer-events: none; + transition: opacity 0.3s; +} + +.viewer-hint kbd { + font-family: var(--mono); + background: var(--bg-elev-2); + border: 1px solid var(--border-strong); + border-radius: 5px; + padding: 1px 5px; + font-size: 0.72rem; +} + +/* ---------- Stats overlay ---------- */ +.stats { + position: absolute; + top: 12px; + right: 12px; + min-width: 150px; + background: rgba(12, 13, 16, 0.72); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px 12px; + font-family: var(--mono); + font-size: 0.78rem; + backdrop-filter: blur(8px); + user-select: none; +} + +.stats .stat { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 2px 0; +} + +.stats .stat .k { + color: var(--text-faint); +} + +.stats .stat .v { + color: var(--accent-2); +} + +.stats .stat .v.bad { + color: var(--warn); +} + +.stats .stat .v.crit { + color: var(--danger); +} + +/* ---------- Toast ---------- */ +.toast { + position: absolute; + left: 50%; + top: 16px; + transform: translateX(-50%); + background: var(--bg-elev-2); + border: 1px solid var(--border-strong); + color: var(--text); + padding: 10px 16px; + border-radius: 10px; + font-size: 0.85rem; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + z-index: 50; + max-width: 80%; +} + +.toast.err { + border-color: var(--danger); + color: #ffd9dd; +} + +.toast.ok { + border-color: var(--accent-2); +} + +/* ---------- QR scanning ---------- */ +.qr-video { + width: 100%; + border-radius: 10px; + background: #000; + display: block; + margin-top: 10px; +} + +.qr-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 24px; +} + +.qr-modal .box { + width: min(420px, 100%); + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +.qr-modal .box h3 { + margin-bottom: 10px; + font-size: 1rem; +} + +.muted { + color: var(--text-faint); + font-size: 0.76rem; + margin-top: 8px; + line-height: 1.5; +} diff --git a/src/web/tsconfig.json b/src/web/tsconfig.json new file mode 100644 index 0000000..c4ae8e1 --- /dev/null +++ b/src/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitAny": true, + "skipLibCheck": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "allowJs": false, + "noEmit": true, + "types": [] + }, + "include": ["src", "vite-env.d.ts"] +} diff --git a/src/web/vite-env.d.ts b/src/web/vite-env.d.ts new file mode 100644 index 0000000..8a47201 --- /dev/null +++ b/src/web/vite-env.d.ts @@ -0,0 +1,67 @@ +/// + +// WebCodecs / WebTransport / BarcodeDetector shims for older lib.dom typings. +// All major evergreen browsers ship these; we only need compile-time types. + +interface VideoFrame { + readonly timestamp: number + readonly duration: number | null + close(): void +} + +interface EncodedVideoChunkInit { + type: 'key' | 'delta' + timestamp: number + duration?: number + data: BufferSource +} + +declare class EncodedVideoChunk { + constructor(init: EncodedVideoChunkInit) + readonly type: 'key' | 'delta' + readonly timestamp: number + readonly duration: number | null + copyTo(destination: BufferSource): void +} + +interface VideoDecoderConfig { + codec: string + width?: number + height?: number + description?: BufferSource + optimizeForLatency?: boolean +} + +interface VideoDecoderInit { + output: (frame: VideoFrame) => void + error: (error: DOMException) => void +} + +declare class VideoDecoder { + constructor(init: VideoDecoderInit) + readonly state: 'unconfigured' | 'configured' | 'closed' + configure(config: VideoDecoderConfig): void + decode(chunk: EncodedVideoChunk): void + flush(): Promise + reset(): void + close(): void +} + +interface VideoDecoderHeapSizeLimit { + readonly maxEncodedBytes: number +} + +// BarcodeDetector (Chrome/Edge) for QR scanning of connect payloads. +interface DetectedBarcode { + readonly rawValue: string + readonly format: string +} + +declare class BarcodeDetector { + constructor(options?: { formats?: string[] }) + detect(source: CanvasImageSource | Blob): Promise +} + +interface Window { + BarcodeDetector?: typeof BarcodeDetector +} diff --git a/src/web/vite.config.ts b/src/web/vite.config.ts new file mode 100644 index 0000000..ead4001 --- /dev/null +++ b/src/web/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' + +// The agent embeds `web/dist` (this directory's build output) via go:embed and +// serves it on :52022. We use relative base so assets resolve no matter the +// mount path, and we keep a stable, reviewable build (no framework). +export default defineConfig({ + base: './', + build: { + outDir: 'dist', + target: 'es2022', + sourcemap: false, + assetsInlineLimit: 0, + chunkSizeWarningLimit: 1024 + }, + server: { + port: 52023, + host: true + } +}) From 005e9b519d8ecf70cd7fc48a7620fff2f4b59b11 Mon Sep 17 00:00:00 2001 From: spacedouut Date: Wed, 26 Aug 2026 11:50:31 -0400 Subject: [PATCH 2/8] spike: distance-web viewer UI (input, connect, stats) + embed dist on :52022 --- .gitignore | 4 +- src/main.go | 4 - src/web.go | 57 +- src/web/dist/assets/index-CLJ4Wr99.js | 2 + src/web/dist/assets/index-J6iVdnoc.css | 1 + src/web/{ => dist}/index.html | 3 +- src/web/package-lock.json | 1062 ++++++++++++++++++++++++ src/web/package.json | 5 +- src/web/src/decoder.ts | 157 ++++ src/web/src/input.ts | 171 ++++ src/web/src/main.ts | 120 +++ src/web/src/transport.ts | 233 ++++++ src/web/src/types.ts | 53 ++ src/web/src/ui/connect.ts | 324 ++++++++ src/web/src/ui/stats.ts | 59 ++ src/web/src/util.ts | 61 ++ src/web/vite-env.d.ts | 55 +- 17 files changed, 2306 insertions(+), 65 deletions(-) create mode 100644 src/web/dist/assets/index-CLJ4Wr99.js create mode 100644 src/web/dist/assets/index-J6iVdnoc.css rename src/web/{ => dist}/index.html (84%) create mode 100644 src/web/package-lock.json create mode 100644 src/web/src/decoder.ts create mode 100644 src/web/src/input.ts create mode 100644 src/web/src/main.ts create mode 100644 src/web/src/transport.ts create mode 100644 src/web/src/types.ts create mode 100644 src/web/src/ui/connect.ts create mode 100644 src/web/src/ui/stats.ts create mode 100644 src/web/src/util.ts diff --git a/.gitignore b/.gitignore index 30cfb16..f57e578 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ env/ build/ # macOS -.DS_STORE \ No newline at end of file +.DS_STORE +# web node modules +/src/web/node_modules/ diff --git a/src/main.go b/src/main.go index 623dc96..eff29b4 100644 --- a/src/main.go +++ b/src/main.go @@ -3,7 +3,6 @@ package main import ( "context" "crypto/tls" - _ "embed" "flag" "fmt" "log" @@ -21,9 +20,6 @@ import ( "distancedesktop/agent/src/backend" ) -//go:embed web/index.html -var webHTML string - // backendOpts holds parsed -- flag values. type backendOpts struct { captured string diff --git a/src/web.go b/src/web.go index 9980b28..8b95c82 100644 --- a/src/web.go +++ b/src/web.go @@ -1,19 +1,33 @@ package main import ( - _ "embed" + "crypto/tls" + "embed" "encoding/json" + "io/fs" "log" "net" "net/http" + "strings" ) +//go:embed web/dist +var webDistFS embed.FS + +// startWebUI serves the built distance-web single-page app (web/dist) over +// HTTPS on :52022 and exposes /api/info for the viewer to auto-discover the +// agent fingerprint + IPs. +// +// HTTPS (not plaintext HTTP) is required so the page is a secure context and +// can open a WebTransport connection with a pinned self-signed certificate. func startWebUI(addr string, cm *certManager) { + sub, err := fs.Sub(webDistFS, "web/dist") + if err != nil { + log.Fatalf("web ui: embed sub: %v", err) + } + fileServer := http.FileServer(http.FS(sub)) + mux := http.NewServeMux() - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write([]byte(webHTML)) - }) mux.HandleFunc("/api/info", func(w http.ResponseWriter, r *http.Request) { ips := localIPs() w.Header().Set("Content-Type", "application/json") @@ -22,8 +36,37 @@ func startWebUI(addr string, cm *certManager) { "ips": ips, }) }) - log.Printf("Web UI on http://%s", addr) - if err := http.ListenAndServe(addr, mux); err != nil { + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // SPA fallback: known files are served directly, everything else falls + // back to index.html so client-side routing works. + path := strings.TrimPrefix(r.URL.Path, "/") + if path == "" { + path = "index.html" + } + if _, statErr := fs.Stat(sub, path); statErr != nil { + data, readErr := fs.ReadFile(sub, "index.html") + if readErr != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) + return + } + fileServer.ServeHTTP(w, r) + }) + + tlsCfg := &tls.Config{ + GetCertificate: cm.getCertificate, + NextProtos: []string{"h2", "http/1.1"}, + } + srv := &http.Server{ + Addr: addr, + Handler: mux, + TLSConfig: tlsCfg, + } + log.Printf("Web UI on https://%s", addr) + if err := srv.ListenAndServeTLS("", ""); err != nil { log.Printf("web ui: %v", err) } } diff --git a/src/web/dist/assets/index-CLJ4Wr99.js b/src/web/dist/assets/index-CLJ4Wr99.js new file mode 100644 index 0000000..4e15d42 --- /dev/null +++ b/src/web/dist/assets/index-CLJ4Wr99.js @@ -0,0 +1,2 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))s(n);new MutationObserver(n=>{for(const o of n)if(o.type==="childList")for(const a of o.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&s(a)}).observe(document,{childList:!0,subtree:!0});function e(n){const o={};return n.integrity&&(o.integrity=n.integrity),n.referrerPolicy&&(o.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?o.credentials="include":n.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function s(n){if(n.ep)return;n.ep=!0;const o=e(n);fetch(n.href,o)}})();function r(i,t={},e=[]){const s=document.createElement(i);for(const[n,o]of Object.entries(t))o!==void 0&&(n==="class"?s.className=o:n==="text"?s.textContent=o:s.setAttribute(n,o));for(const n of e)s.append(n);return s}function H(i){const t=i.replace(/[^0-9a-fA-F]/g,"");if(t.length%2!==0)throw new Error("invalid hex length");const e=new Uint8Array(t.length/2);for(let s=0;ss.classList.add("hidden"),e))}class N{wt=null;ctrlWriter=null;ctrlReader=null;msgHandler=null;videoHandler=null;statsHandler=null;ctrlBuf=new Uint8Array(0);enc=new TextEncoder;dec=new TextDecoder;rxBytes=0;windowBytes=0;lastWindow=performance.now();bitrate=0;pendingSince=0;rtt=0;statsTimer;closed=!1;onMessage(t){this.msgHandler=t}setVideoHandler(t){this.videoHandler=t}onStats(t){this.statsHandler=t}get connected(){return this.wt!==null}async connect(t){const e=H(t.fingerprintHex);if(e.length!==32)throw new Error("fingerprint must be a 32-byte SHA-256 hex string");const s=new WebTransport(t.url,{serverCertificateHashes:[{algorithm:"sha-256",value:e}]});this.wt=s,s.closed.then(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})}),await s.ready;const n=await s.createBidirectionalStream();this.ctrlWriter=n.writable.getWriter(),this.ctrlReader=n.readable.getReader(),this.readControlLoop(),this.readVideoLoop(),this.statsTimer=window.setInterval(()=>this.tickStats(),1e3)}send(t){if(!this.ctrlWriter)throw new Error("not connected");(t.type==="list-displays"||t.type==="start")&&(this.pendingSince=performance.now());const e=this.enc.encode(JSON.stringify(t)+` +`);this.ctrlWriter.write(e)}listDisplays(){this.send({type:"list-displays"})}start(t,e={}){this.send({type:"start",display_id:t,...e})}stop(){this.send({type:"stop"})}getStats(){return{bitrate:this.bitrate,rtt:this.rtt,rxBytes:this.rxBytes}}close(){this.closed=!0,this.statsTimer&&window.clearInterval(this.statsTimer);try{this.ctrlWriter?.close()}catch{}try{this.wt?.close()}catch{}}async readControlLoop(){const t=this.ctrlReader;if(t)try{for(;;){const{value:e,done:s}=await t.read();if(s)break;if(!e)continue;this.ctrlBuf=O(this.ctrlBuf,e);let n;for(;(n=W(this.ctrlBuf))!==-1;){const o=this.dec.decode(this.ctrlBuf.subarray(0,n));this.ctrlBuf=this.ctrlBuf.subarray(n+1);const a=o.trim();if(!a)continue;let l;try{l=JSON.parse(a)}catch{continue}(l.type==="displays"||l.type==="started")&&this.pendingSince&&(this.rtt=Math.round(performance.now()-this.pendingSince),this.pendingSince=0),this.msgHandler?.(l)}}}catch{}}async readVideoLoop(){const t=this.wt;if(t)try{const e=t.incomingUnidirectionalStreams.getReader();for(;;){const{value:s,done:n}=await e.read();if(n)break;if(!s)continue;const o=s.readable.getReader();for(;;){const{value:a,done:l}=await o.read();if(l)break;a&&(this.rxBytes+=a.byteLength,this.windowBytes+=a.byteLength,this.videoHandler?.(a))}}}catch{}}tickStats(){const t=performance.now(),e=(t-this.lastWindow)/1e3;e>0&&(this.bitrate=Math.round(this.windowBytes*8/e),this.windowBytes=0,this.lastWindow=t),this.statsHandler?.(this.getStats())}}function O(i,t){const e=new Uint8Array(i.length+t.length);return e.set(i,0),e.set(t,i.length),e}function W(i){for(let t=0;t"u")throw new Error("WebCodecs VideoDecoder is not available in this browser")}configure(t,e,s){if(this.videoDecoder&&this.videoDecoder.state!=="closed")try{this.videoDecoder.reset()}catch{}this.videoDecoder=new VideoDecoder({output:n=>this.onFrame(n),error:n=>console.error("[decoder] error",n)}),this.videoDecoder.configure(M(t,e,s))}onFrame(t){const e=t.displayWidth,s=t.displayHeight;(this.canvas.width!==e||this.canvas.height!==s)&&(this.canvas.width=e,this.canvas.height=s),this.ctx.drawImage(t,0,0,e,s),t.close(),this.frameCount++,this.firstFrameDrawn||(this.firstFrameDrawn=!0,this.onFirstFrame?.())}feed(t){if(!this.videoDecoder||this.videoDecoder.state!=="configured")return;this.buf=P(this.buf,t);let e=0,s=0;const n=this.buf;for(;s+2=1&&(this.fps=Math.round(this.frameCount/e),this.frameCount=0,this.fpsWindowStart=t,this.onFps?.(this.fps))}get currentFps(){return this.fps}reset(){this.buf=new Uint8Array(0),this.dts=0;try{this.videoDecoder?.reset()}catch{}}close(){try{this.videoDecoder?.close()}catch{}this.videoDecoder=null}}function P(i,t){const e=new Uint8Array(i.length+t.length);return e.set(i,0),e.set(t,i.length),e}class U{send;target=null;locked=!1;bound=[];constructor(t){this.send=t}attach(t){this.target=t,this.on(t,"click",()=>{!this.locked&&t.requestPointerLock&&t.requestPointerLock()}),this.on(document,"pointerlockchange",()=>{this.locked=document.pointerLockElement===t,t.classList.toggle("locked",this.locked),this.locked&&this.tryKeyboardLock()}),this.on(t,"mousemove",e=>{if(!this.locked)return;const s=e;this.send({type:"input",kind:"mouse",dx:s.movementX,dy:s.movementY,buttons:s.buttons})}),this.on(t,"mousedown",e=>{const s=e;this.send({type:"input",kind:"mousedown",button:s.button})}),this.on(t,"mouseup",e=>{const s=e;this.send({type:"input",kind:"mouseup",button:s.button})}),this.on(t,"wheel",e=>{if(!this.locked)return;e.preventDefault();const s=e;this.send({type:"input",kind:"wheel",dx:s.deltaX,dy:s.deltaY})},{passive:!1}),this.on(t,"contextmenu",e=>e.preventDefault()),this.on(window,"keydown",e=>this.onKey(e,!0)),this.on(window,"keyup",e=>this.onKey(e,!1)),this.on(t,"touchstart",e=>this.onTouch(e,"start"),{passive:!1}),this.on(t,"touchmove",e=>this.onTouch(e,"move"),{passive:!1}),this.on(t,"touchend",e=>this.onTouch(e,"end"),{passive:!1})}get isLocked(){return this.locked}release(){this.locked&&document.exitPointerLock&&document.exitPointerLock()}detach(){this.release();for(const[t,e,s,n]of this.bound)t.removeEventListener(e,s,n);this.bound=[],this.target=null}on(t,e,s,n){t.addEventListener(e,s,n),this.bound.push([t,e,s,n??!1])}tryKeyboardLock(){const t=navigator.keyboard;t?.lock&&t.lock(["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"]).catch(()=>{})}onKey(t,e){if(!this.locked)return;["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","'","/","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"].includes(t.key)&&t.preventDefault(),this.send({type:"input",kind:"key",code:t.code,down:e})}onTouch(t,e){if(!this.target)return;t.preventDefault();const s=this.target.getBoundingClientRect(),n=t.changedTouches;for(let o=0;othis.doConnect());const F=r("button",{class:"btn ghost",text:"Scan QR"});F.addEventListener("click",()=>this.openQr()),w.append(this.connectBtn,F);const S=r("div",{class:"field"},[r("label",{text:"…or paste a connection JSON"})]),g=r("textarea",{placeholder:'{"host":"10.10.1.5","port":52020,"fingerprint":"abcd…","label":"pc"}',spellcheck:"false"});g.addEventListener("change",()=>this.applyJson(g.value)),S.append(g),t.append(s,n,l,w,S);const L=this.loadRecent();if(L.length){const I=r("div",{class:"recent"},[r("h2",{text:"Recent hosts"})]),E=r("div",{class:"recent-list"});for(const c of L){const v=r("div",{class:"recent-item"}),T=r("div",{class:"meta"},[r("div",{class:"host",text:c.label?`${c.label} · ${c.host}:${c.port}`:`${c.host}:${c.port}`}),r("div",{class:"fp",text:c.fingerprint})]);v.append(T);const D=r("button",{class:"del",title:"forget",text:"×"});D.addEventListener("click",A=>{A.stopPropagation(),this.forget(c.fingerprint+c.host),this.render()}),v.append(D),v.addEventListener("click",()=>{this.fpInput.value=c.fingerprint,this.hostInput.value=c.host,this.portInput.value=String(c.port),c.label&&(this.labelInput.value=c.label)}),E.append(v)}I.append(E),t.append(I)}this.root.append(t),this.fpInput.value=e.fingerprint??""}suggestFromPage(){const t={host:location.hostname||"",port:52020,fingerprint:void 0};return fetch("/api/info").then(e=>e.json()).then(e=>{e?.fingerprint&&(this.fpInput.value=e.fingerprint),Array.isArray(e?.ips)&&e.ips.length&&!this.hostInput.value&&(this.hostInput.value=e.ips[0])}).catch(()=>{}),t}async doConnect(){const t=this.fpInput.value.trim().replace(/\s+/g,""),e=this.hostInput.value.trim(),s=parseInt(this.portInput.value.trim(),10)||52020,n=this.labelInput.value.trim()||void 0;if(!/^[0-9a-fA-F]{64}$/.test(t)){d("Fingerprint must be 64 hex characters","err");return}if(!e){d("Enter the agent host","err");return}this.connectBtn.disabled=!0,this.connectBtn.textContent="Connecting…";try{const o=await this.deps.connectAndList({host:e,port:s,fingerprint:t,label:n});this.deps.onConnected?.({host:e,port:s,fingerprint:t,label:n}),this.showDisplays(o,{host:e,port:s,fingerprint:t,label:n})}catch(o){d(`Connection failed: ${o.message}`,"err"),this.connectBtn.disabled=!1,this.connectBtn.textContent="Connect"}}showDisplays(t,e){this.root.innerHTML="";const s=r("div",{class:"connect-card"});s.append(r("div",{class:"brand"},[r("span",{class:"dot"}),r("h1",{text:"Choose a display"})]),r("div",{class:"subtitle",text:`${e.host}:${e.port}`})),t.length||s.append(r("div",{class:"muted",text:"No displays reported by the agent yet."}));const n=r("div",{class:"displays"});for(const o of t){const a=r("button",{class:"display-tile"},[r("div",{class:"name",text:`Display ${o.id}`}),r("div",{class:"res",text:`${o.width}×${o.height} @ ${o.refresh_rate||"?"}Hz`})]);a.addEventListener("click",()=>{this.deps.startStream(o.id),this.root.classList.add("hidden")}),n.append(a)}s.append(n),this.root.append(s)}applyJson(t){let e;try{e=JSON.parse(t.trim())}catch{return d("Invalid connection JSON","err"),!1}return e.fingerprint&&(this.fpInput.value=e.fingerprint),e.host&&(this.hostInput.value=e.host),e.port&&(this.portInput.value=String(e.port)),e.label&&(this.labelInput.value=e.label),d("Filled from JSON","ok"),!0}openQr(){if(!window.BarcodeDetector){d("QR scanning requires Chrome/Edge with BarcodeDetector","err");return}const t=r("div",{class:"qr-modal"}),e=r("div",{class:"box"},[r("h3",{text:"Scan connection QR"})]),s=r("video",{class:"qr-video",playsinline:"true"});e.append(s),e.append(r("div",{class:"muted",text:"Point your camera at the agent’s QR code."}));const n=r("button",{class:"btn ghost",text:"Cancel"});n.addEventListener("click",()=>this.stopQr(t)),e.append(r("div",{class:"actions"},[n])),t.append(e),document.body.append(t),navigator.mediaDevices.getUserMedia({video:{facingMode:"environment"}}).then(async o=>{this.qrStream=o,s.srcObject=o,await s.play(),this.scanLoop(s,t)}).catch(()=>{d("Camera unavailable","err"),this.stopQr(t)})}async scanLoop(t,e){if(!this.qrStream||!window.BarcodeDetector)return;const s=new BarcodeDetector({formats:["qr"]}),n=async()=>{if(this.qrStream){try{const o=await s.detect(t);for(const a of o)if(this.applyJson(a.rawValue)){this.stopQr(e);return}}catch{}requestAnimationFrame(n)}};n()}stopQr(t){this.qrStream?.getTracks().forEach(e=>e.stop()),this.qrStream=null,t.remove()}loadRecent(){try{const t=localStorage.getItem(y);return t?JSON.parse(t).sort((s,n)=>n.last-s.last).slice(0,8):[]}catch{return[]}}remember(t){try{const e=this.loadRecent().filter(s=>!(s.host===t.host&&s.port===(t.port??52020)));e.unshift({host:t.host,port:t.port??52020,fingerprint:t.fingerprint,label:t.label,last:Date.now()}),localStorage.setItem(y,JSON.stringify(e.slice(0,8)))}catch{}}forget(t){try{const e=this.loadRecent().filter(s=>s.fingerprint+s.host!==t);localStorage.setItem(y,JSON.stringify(e))}catch{}}saveRecent(t){this.remember(t)}}class V{root;visible=!1;last={fps:0,rtt:0,bitrate:0,width:0,height:0,online:!1};constructor(t){this.root=t}toggle(){this.visible=!this.visible,this.root.classList.toggle("hidden",!this.visible),this.visible&&this.render()}show(){this.visible=!0,this.root.classList.remove("hidden"),this.render()}get isVisible(){return this.visible}update(t){this.last={...this.last,...t},this.visible&&this.render()}row(t,e,s=""){return`
${t}${e}
`}render(){const t=this.last,e=t.rtt>150?"crit":t.rtt>60?"bad":"",s=t.fps===0?"bad":"",n=t.bitrate===0?"bad":"";this.root.innerHTML=this.row("fps",t.fps?`${t.fps}`:"—",s)+this.row("rtt",t.rtt?`${t.rtt} ms`:"—",e)+this.row("bitrate",t.bitrate?R(t.bitrate):"—",n)+this.row("res",t.width?`${t.width}×${t.height}`:"—")+this.row("link",t.online?"up":"down",t.online?"":"crit")}}const b=document.getElementById("connect"),k=document.getElementById("viewer"),C=document.getElementById("stage"),K=document.getElementById("stats"),B=document.getElementById("viewer-hint"),p=new N,u=new J(C),f=new V(K),$=new U(i=>p.send(i));let h=null;p.onMessage(i=>j(i));p.setVideoHandler(i=>u.feed(i));p.onStats(i=>f.update({bitrate:i.bitrate,rtt:i.rtt,online:p.connected}));u.onFps=i=>f.update({fps:i});u.onFirstFrame=()=>f.show();function j(i){switch(i.type){case"displays":h&&(h.resolve(i.displays),h=null);break;case"started":Y(i);break;case"error":h?(h.reject(new Error(i.message)),h=null):d(`Agent: ${i.message}`,"err");break;case"stopped":case"stream-ended":X(i.type);break;case"fingerprint-refresh":d("Agent certificate rotated — reconnect to refresh fingerprint","info");break}}function Y(i){u.configure(i.codec,i.width,i.height),b.classList.add("hidden"),k.classList.remove("hidden"),f.show(),$.attach(C),_(),d(`Streaming ${i.width}×${i.height}`,"ok")}function X(i){d(i==="stopped"?"Stream stopped":"Stream ended","info"),$.detach(),u.reset(),k.classList.add("hidden"),b.classList.remove("hidden"),x.render()}function _(){window.setTimeout(()=>{B&&(B.style.opacity="0")},6e3)}window.addEventListener("keydown",i=>{if(i.key==="~"||i.key==="`"){if(k.classList.contains("hidden"))return;f.toggle()}});const z={connectAndList:i=>new Promise((t,e)=>{const s=q(i.host,i.port??52020);p.connect({url:s,fingerprintHex:i.fingerprint}).then(()=>{h={resolve:t,reject:e},p.listDisplays(),window.setTimeout(()=>{h&&(h.reject(new Error("timed out waiting for displays")),h=null)},8e3)}).catch(e)}),startStream:i=>{p.start(i)},onConnected:i=>x.saveRecent(i)},x=new Q(b,z);x.render(); diff --git a/src/web/dist/assets/index-J6iVdnoc.css b/src/web/dist/assets/index-J6iVdnoc.css new file mode 100644 index 0000000..ffbe0b0 --- /dev/null +++ b/src/web/dist/assets/index-J6iVdnoc.css @@ -0,0 +1 @@ +:root{--bg: #0c0d10;--bg-elev: #15171c;--bg-elev-2: #1d2026;--border: #2a2e37;--border-strong: #3a404c;--text: #e7e9ee;--text-dim: #9aa1ad;--text-faint: #6b7280;--accent: #5b8cff;--accent-2: #3df0a8;--danger: #ff5c6c;--warn: #ffcf5c;--mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, monospace;--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--radius: 12px}*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}body{font-family:var(--sans);background:radial-gradient(1200px 800px at 70% -10%,#161a22 0%,var(--bg) 60%);color:var(--text);overflow:hidden;-webkit-font-smoothing:antialiased}#app{position:fixed;inset:0}.screen{position:absolute;inset:0}.hidden{display:none!important}#connect{display:flex;align-items:center;justify-content:center;padding:24px;overflow:auto}.connect-card{width:min(560px,100%);background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius);padding:28px;box-shadow:0 20px 60px #00000073}.brand{display:flex;align-items:center;gap:12px;margin-bottom:4px}.brand .dot{width:12px;height:12px;border-radius:50%;background:var(--accent-2);box-shadow:0 0 12px var(--accent-2)}.brand h1{font-size:1.25rem;font-weight:650;letter-spacing:.2px}.subtitle{color:var(--text-dim);font-size:.85rem;margin-bottom:22px}.field{margin-bottom:16px}.field>label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);margin-bottom:6px}input[type=text],input[type=number],textarea{width:100%;background:var(--bg-elev-2);border:1px solid var(--border);border-radius:9px;color:var(--text);font-family:var(--mono);font-size:.85rem;padding:10px 12px;outline:none;transition:border-color .15s,box-shadow .15s}input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px #5b8cff2e}textarea{resize:vertical;min-height:64px;word-break:break-all;line-height:1.5}.row{display:flex;gap:12px}.row>.field{flex:1}.btn{appearance:none;border:1px solid var(--border-strong);background:var(--bg-elev-2);color:var(--text);font-size:.85rem;font-weight:550;padding:10px 14px;border-radius:9px;cursor:pointer;transition:background .15s,border-color .15s,transform .05s}.btn:hover{border-color:var(--accent)}.btn:active{transform:translateY(1px)}.btn.primary{background:linear-gradient(180deg,#5b8cff,#3f6fe0);border-color:#3f6fe0;color:#fff}.btn.primary:hover{filter:brightness(1.06)}.btn.ghost{background:transparent}.btn:disabled{opacity:.5;cursor:not-allowed}.actions{display:flex;gap:10px;margin-top:6px}.actions .btn.primary{flex:1}.recent{margin-top:22px;border-top:1px solid var(--border);padding-top:16px}.recent h2{font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);margin-bottom:10px}.recent-list{display:flex;flex-direction:column;gap:8px}.recent-item{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-elev-2);border:1px solid var(--border);border-radius:9px;cursor:pointer}.recent-item:hover{border-color:var(--accent)}.recent-item .meta{flex:1;min-width:0}.recent-item .host{font-family:var(--mono);font-size:.82rem}.recent-item .fp{font-family:var(--mono);font-size:.68rem;color:var(--text-faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.recent-item .del{color:var(--text-faint);border:none;background:none;cursor:pointer;font-size:1rem;padding:2px 6px}.recent-item .del:hover{color:var(--danger)}.displays{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:14px}.display-tile{background:var(--bg-elev-2);border:1px solid var(--border);border-radius:10px;padding:14px;cursor:pointer;text-align:left}.display-tile:hover{border-color:var(--accent)}.display-tile .name{font-weight:600;font-size:.9rem}.display-tile .res{color:var(--text-dim);font-size:.78rem;margin-top:4px;font-family:var(--mono)}#viewer{background:#000}#stage{width:100%;height:100%;display:block;object-fit:contain;cursor:none;background:#000}#stage:not(.locked){cursor:default}.viewer-hint{position:absolute;left:50%;bottom:18px;transform:translate(-50%);background:#0000008c;border:1px solid var(--border);color:var(--text-dim);font-size:.76rem;padding:7px 12px;border-radius:999px;backdrop-filter:blur(6px);pointer-events:none;transition:opacity .3s}.viewer-hint kbd{font-family:var(--mono);background:var(--bg-elev-2);border:1px solid var(--border-strong);border-radius:5px;padding:1px 5px;font-size:.72rem}.stats{position:absolute;top:12px;right:12px;min-width:150px;background:#0c0d10b8;border:1px solid var(--border);border-radius:10px;padding:10px 12px;font-family:var(--mono);font-size:.78rem;backdrop-filter:blur(8px);user-select:none}.stats .stat{display:flex;justify-content:space-between;gap:16px;padding:2px 0}.stats .stat .k{color:var(--text-faint)}.stats .stat .v{color:var(--accent-2)}.stats .stat .v.bad{color:var(--warn)}.stats .stat .v.crit{color:var(--danger)}.toast{position:absolute;left:50%;top:16px;transform:translate(-50%);background:var(--bg-elev-2);border:1px solid var(--border-strong);color:var(--text);padding:10px 16px;border-radius:10px;font-size:.85rem;box-shadow:0 12px 40px #00000080;z-index:50;max-width:80%}.toast.err{border-color:var(--danger);color:#ffd9dd}.toast.ok{border-color:var(--accent-2)}.qr-video{width:100%;border-radius:10px;background:#000;display:block;margin-top:10px}.qr-modal{position:fixed;inset:0;background:#000000b3;display:flex;align-items:center;justify-content:center;z-index:100;padding:24px}.qr-modal .box{width:min(420px,100%);background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius);padding:20px}.qr-modal .box h3{margin-bottom:10px;font-size:1rem}.muted{color:var(--text-faint);font-size:.76rem;margin-top:8px;line-height:1.5} diff --git a/src/web/index.html b/src/web/dist/index.html similarity index 84% rename from src/web/index.html rename to src/web/dist/index.html index cb0f3e0..67b0f24 100644 --- a/src/web/index.html +++ b/src/web/dist/index.html @@ -5,6 +5,8 @@ Distance Desktop + +
@@ -22,6 +24,5 @@
- diff --git a/src/web/package-lock.json b/src/web/package-lock.json new file mode 100644 index 0000000..455f75c --- /dev/null +++ b/src/web/package-lock.json @@ -0,0 +1,1062 @@ +{ + "name": "distance-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "distance-web", + "version": "0.1.0", + "devDependencies": { + "typescript": "5.6.3", + "vite": "^5.4.8" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz", + "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz", + "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz", + "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz", + "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz", + "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz", + "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz", + "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz", + "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz", + "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz", + "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz", + "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz", + "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz", + "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz", + "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz", + "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz", + "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz", + "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz", + "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz", + "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz", + "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz", + "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz", + "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz", + "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz", + "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz", + "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz", + "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.0", + "@rollup/rollup-android-arm64": "4.63.0", + "@rollup/rollup-darwin-arm64": "4.63.0", + "@rollup/rollup-darwin-x64": "4.63.0", + "@rollup/rollup-freebsd-arm64": "4.63.0", + "@rollup/rollup-freebsd-x64": "4.63.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.0", + "@rollup/rollup-linux-arm-musleabihf": "4.63.0", + "@rollup/rollup-linux-arm64-gnu": "4.63.0", + "@rollup/rollup-linux-arm64-musl": "4.63.0", + "@rollup/rollup-linux-loong64-gnu": "4.63.0", + "@rollup/rollup-linux-loong64-musl": "4.63.0", + "@rollup/rollup-linux-ppc64-gnu": "4.63.0", + "@rollup/rollup-linux-ppc64-musl": "4.63.0", + "@rollup/rollup-linux-riscv64-gnu": "4.63.0", + "@rollup/rollup-linux-riscv64-musl": "4.63.0", + "@rollup/rollup-linux-s390x-gnu": "4.63.0", + "@rollup/rollup-linux-x64-gnu": "4.63.0", + "@rollup/rollup-linux-x64-musl": "4.63.0", + "@rollup/rollup-openbsd-x64": "4.63.0", + "@rollup/rollup-openharmony-arm64": "4.63.0", + "@rollup/rollup-win32-arm64-msvc": "4.63.0", + "@rollup/rollup-win32-ia32-msvc": "4.63.0", + "@rollup/rollup-win32-x64-gnu": "4.63.0", + "@rollup/rollup-win32-x64-msvc": "4.63.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/src/web/package.json b/src/web/package.json index b90c543..bcf1cda 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -10,7 +10,10 @@ "preview": "vite preview" }, "devDependencies": { - "typescript": "^5.5.4", + "typescript": "5.6.3", "vite": "^5.4.8" + }, + "allowScripts": { + "esbuild@0.21.5": true } } diff --git a/src/web/src/decoder.ts b/src/web/src/decoder.ts new file mode 100644 index 0000000..dd17e51 --- /dev/null +++ b/src/web/src/decoder.ts @@ -0,0 +1,157 @@ +// H264 (Annex B) decoder using the platform WebCodecs VideoDecoder, drawing +// straight onto a . The agent sends raw ffmpeg H264 bytes (Annex B with +// 00 00 00 01 / 00 00 01 start codes) on the unidirectional video stream, so we +// split the byte stream into NAL units and feed each to the decoder. + +function mapCodec(codec: string, width?: number, height?: number): any { + let c = 'avc1.42E01E' // Constrained Baseline 3.0 — safe default for H264 + if (codec === 'hevc' || codec === 'h265') c = 'hvc1.1.6.L93.B0' + else if (codec === 'av1') c = 'av01.0.05M.08' + else if (codec === 'vp9') c = 'vp09.00.10.08' + const cfg: any = { codec: c } + if (width && height) { + cfg.width = width + cfg.height = height + } + return cfg +} + +export class Decoder { + private videoDecoder: VideoDecoder | null = null + private canvas: HTMLCanvasElement + private ctx: CanvasRenderingContext2D + private buf = new Uint8Array(0) + private dts = 0 + + private frameCount = 0 + private fpsWindowStart = performance.now() + private fps = 0 + + public onFps: ((fps: number) => void) | null = null + public onFirstFrame: (() => void) | null = null + private firstFrameDrawn = false + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas + const ctx = canvas.getContext('2d', { alpha: false }) + if (!ctx) throw new Error('2d canvas context unavailable') + this.ctx = ctx + if (typeof VideoDecoder === 'undefined') { + throw new Error('WebCodecs VideoDecoder is not available in this browser') + } + } + + configure(codec: string, width?: number, height?: number): void { + if (this.videoDecoder && this.videoDecoder.state !== 'closed') { + try { + this.videoDecoder.reset() + } catch { + /* ignore */ + } + } + this.videoDecoder = new VideoDecoder({ + output: (frame) => this.onFrame(frame), + error: (e) => console.error('[decoder] error', e) + }) + this.videoDecoder.configure(mapCodec(codec, width, height)) + } + + private onFrame(frame: VideoFrame): void { + const w = frame.displayWidth + const h = frame.displayHeight + if (this.canvas.width !== w || this.canvas.height !== h) { + this.canvas.width = w + this.canvas.height = h + } + this.ctx.drawImage(frame as unknown as CanvasImageSource, 0, 0, w, h) + frame.close() + + this.frameCount++ + if (!this.firstFrameDrawn) { + this.firstFrameDrawn = true + this.onFirstFrame?.() + } + } + + /** Feed a raw chunk of video bytes (may contain many NAL units). */ + feed(chunk: Uint8Array): void { + if (!this.videoDecoder || this.videoDecoder.state !== 'configured') return + this.buf = concat(this.buf, chunk) + let start = 0 + let i = 0 + const buf = this.buf + while (i + 2 < buf.length) { + if (buf[i] === 0 && buf[i + 1] === 0 && buf[i + 2] === 1) { + if (start < i) { + const nal = buf.subarray(start, i) + this.decodeNal(nal) + } + start = i + 3 + i = start + continue + } + i++ + } + this.buf = buf.subarray(start) + this.tickFps() + } + + private decodeNal(nal: Uint8Array): void { + if (nal.length === 0) return + const nalType = nal[0] & 0x1f + const type: 'key' | 'delta' = nalType === 5 ? 'key' : 'delta' + this.dts += 1000 // µs, monotonic in decode order + try { + this.videoDecoder!.decode( + new EncodedVideoChunk({ type, timestamp: this.dts, data: nal as unknown as BufferSource }) + ) + } catch (e) { + // Decoding a NAL before SPS/PPS is configured can throw; ignore until + // the stream establishes a valid state. + if (!(e instanceof DOMException && e.name === 'InvalidStateError')) { + console.warn('[decoder] decode threw', e) + } + } + } + + private tickFps(): void { + const now = performance.now() + const dt = (now - this.fpsWindowStart) / 1000 + if (dt >= 1) { + this.fps = Math.round(this.frameCount / dt) + this.frameCount = 0 + this.fpsWindowStart = now + this.onFps?.(this.fps) + } + } + + get currentFps(): number { + return this.fps + } + + reset(): void { + this.buf = new Uint8Array(0) + this.dts = 0 + try { + this.videoDecoder?.reset() + } catch { + /* ignore */ + } + } + + close(): void { + try { + this.videoDecoder?.close() + } catch { + /* ignore */ + } + this.videoDecoder = null + } +} + +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length) + out.set(a, 0) + out.set(b, a.length) + return out +} diff --git a/src/web/src/input.ts b/src/web/src/input.ts new file mode 100644 index 0000000..c8b267e --- /dev/null +++ b/src/web/src/input.ts @@ -0,0 +1,171 @@ +import type { InputMessage } from './types' + +type Send = (msg: InputMessage) => void + +/** + * Captures local input and serializes it to the control bidi stream. + * + * - Pointer Lock gives relative mouse motion (movementX/Y) with no clipping. + * - Keyboard Lock captures key events even when the browser would otherwise + * consume them (Tab, F-keys, etc.). + * - Wheel and multi-touch (normalized 0..1) are forwarded for remote injection. + */ +export class InputController { + private send: Send + private target: HTMLElement | null = null + private locked = false + private bound: Array<[EventTarget, string, EventListenerOrEventListenerObject, AddEventListenerOptions | boolean]> = [] + + constructor(send: Send) { + this.send = send + } + + attach(target: HTMLElement): void { + this.target = target + + this.on(target, 'click', () => { + if (!this.locked && target.requestPointerLock) { + target.requestPointerLock() + } + }) + + this.on(document, 'pointerlockchange', () => { + this.locked = document.pointerLockElement === target + target.classList.toggle('locked', this.locked) + if (this.locked) this.tryKeyboardLock() + }) + + this.on(target, 'mousemove', (e) => { + if (!this.locked) return + const ev = e as MouseEvent + this.send({ type: 'input', kind: 'mouse', dx: ev.movementX, dy: ev.movementY, buttons: ev.buttons }) + }) + + this.on(target, 'mousedown', (e) => { + const ev = e as MouseEvent + this.send({ type: 'input', kind: 'mousedown', button: ev.button }) + }) + + this.on(target, 'mouseup', (e) => { + const ev = e as MouseEvent + this.send({ type: 'input', kind: 'mouseup', button: ev.button }) + }) + + this.on( + target, + 'wheel', + (e) => { + if (!this.locked) return + e.preventDefault() + const ev = e as WheelEvent + this.send({ type: 'input', kind: 'wheel', dx: ev.deltaX, dy: ev.deltaY }) + }, + { passive: false } + ) + + // Suppress the context menu so right-click can be sent to the remote. + this.on(target, 'contextmenu', (e) => e.preventDefault()) + + this.on(window, 'keydown', (e) => this.onKey(e as KeyboardEvent, true)) + this.on(window, 'keyup', (e) => this.onKey(e as KeyboardEvent, false)) + + this.on(target, 'touchstart', (e) => this.onTouch(e as TouchEvent, 'start'), { passive: false }) + this.on(target, 'touchmove', (e) => this.onTouch(e as TouchEvent, 'move'), { passive: false }) + this.on(target, 'touchend', (e) => this.onTouch(e as TouchEvent, 'end'), { passive: false }) + } + + get isLocked(): boolean { + return this.locked + } + + release(): void { + if (this.locked && document.exitPointerLock) document.exitPointerLock() + } + + detach(): void { + this.release() + for (const [t, type, fn, opts] of this.bound) t.removeEventListener(type, fn, opts) + this.bound = [] + this.target = null + } + + private on( + t: EventTarget, + type: string, + fn: (e: Event) => void, + opts?: AddEventListenerOptions | boolean + ): void { + t.addEventListener(type, fn, opts) + this.bound.push([t, type, fn, opts ?? false]) + } + + private tryKeyboardLock(): void { + const kb = (navigator as Navigator & { keyboard?: { lock?: (codes?: string[]) => Promise } }).keyboard + if (!kb?.lock) return + // Best-effort: capture a broad set of keys. Failures are non-fatal. + kb.lock([ + 'Tab', + 'Space', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'F1', + 'F2', + 'F3', + 'F4', + 'F5', + 'F6', + 'F7', + 'F8', + 'F9', + 'F10', + 'F11', + 'F12' + ]).catch(() => { + /* keyboard lock may require a transient activation; ignore */ + }) + } + + private onKey(e: KeyboardEvent, down: boolean): void { + if (!this.locked) return + // Stop the page from acting on keys we forward (scroll, find, etc.) + const swallow = [ + 'Tab', + 'Space', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + "'", + '/', + 'F1', + 'F2', + 'F3', + 'F4', + 'F5', + 'F6', + 'F7', + 'F8', + 'F9', + 'F10', + 'F11', + 'F12' + ] + if (swallow.includes(e.key)) e.preventDefault() + this.send({ type: 'input', kind: 'key', code: e.code, down }) + } + + private onTouch(e: TouchEvent, phase: 'start' | 'move' | 'end'): void { + if (!this.target) return + e.preventDefault() + const rect = this.target.getBoundingClientRect() + const touches = e.changedTouches + for (let i = 0; i < touches.length; i++) { + const t = touches[i] + const x = rect.width ? (t.clientX - rect.left) / rect.width : 0 + const y = rect.height ? (t.clientY - rect.top) / rect.height : 0 + this.send({ type: 'input', kind: 'touch', id: t.identifier, x, y, phase }) + } + } +} diff --git a/src/web/src/main.ts b/src/web/src/main.ts new file mode 100644 index 0000000..3cd8f3f --- /dev/null +++ b/src/web/src/main.ts @@ -0,0 +1,120 @@ +import '../style.css' +import { Transport, wtUrl } from './transport' +import { Decoder } from './decoder' +import { InputController } from './input' +import { ConnectScreen, type ConnectDeps } from './ui/connect' +import { StatsOverlay } from './ui/stats' +import { toast } from './util' +import type { ConnectPayload, ControlMessage, Display, InputMessage } from './types' + +const connectEl = document.getElementById('connect')! +const viewerEl = document.getElementById('viewer')! +const canvas = document.getElementById('stage') as HTMLCanvasElement +const statsEl = document.getElementById('stats')! +const hintEl = document.getElementById('viewer-hint')! + +const transport = new Transport() +const decoder = new Decoder(canvas) +const stats = new StatsOverlay(statsEl) +const input = new InputController((m: InputMessage) => transport.send(m)) + +let pendingList: { resolve: (d: Display[]) => void; reject: (e: Error) => void } | null = null + +transport.onMessage((msg: ControlMessage) => handleMessage(msg)) +transport.setVideoHandler((chunk) => decoder.feed(chunk)) +transport.onStats((s) => stats.update({ bitrate: s.bitrate, rtt: s.rtt, online: transport.connected })) + +decoder.onFps = (fps) => stats.update({ fps }) +decoder.onFirstFrame = () => stats.show() + +function handleMessage(msg: ControlMessage): void { + switch (msg.type) { + case 'displays': + if (pendingList) { + pendingList.resolve(msg.displays) + pendingList = null + } + break + case 'started': + onStarted(msg) + break + case 'error': + if (pendingList) { + pendingList.reject(new Error(msg.message)) + pendingList = null + } else { + toast(`Agent: ${msg.message}`, 'err') + } + break + case 'stopped': + case 'stream-ended': + onStreamEnded(msg.type) + break + case 'fingerprint-refresh': + // Server rotated its cert; a reconnect would be required with the new + // fingerprint. Surfaced for awareness. + toast('Agent certificate rotated — reconnect to refresh fingerprint', 'info') + break + } +} + +function onStarted(msg: Extract): void { + decoder.configure(msg.codec, msg.width, msg.height) + connectEl.classList.add('hidden') + viewerEl.classList.remove('hidden') + stats.show() + input.attach(canvas) + fadeHint() + toast(`Streaming ${msg.width}×${msg.height}`, 'ok') +} + +function onStreamEnded(kind: string): void { + toast(kind === 'stopped' ? 'Stream stopped' : 'Stream ended', 'info') + input.detach() + decoder.reset() + viewerEl.classList.add('hidden') + connectEl.classList.remove('hidden') + connectScreen.render() +} + +function fadeHint(): void { + window.setTimeout(() => { + if (hintEl) hintEl.style.opacity = '0' + }, 6000) +} + +// Press ~ to toggle the stats overlay. +window.addEventListener('keydown', (e) => { + if (e.key === '~' || e.key === '`') { + if (viewerEl.classList.contains('hidden')) return + stats.toggle() + } +}) + +const deps: ConnectDeps = { + connectAndList: (payload: ConnectPayload) => + new Promise((resolve, reject) => { + const url = wtUrl(payload.host, payload.port ?? 52020) + transport + .connect({ url, fingerprintHex: payload.fingerprint }) + .then(() => { + pendingList = { resolve, reject } + transport.listDisplays() + // Safety timeout in case the agent never answers. + window.setTimeout(() => { + if (pendingList) { + pendingList.reject(new Error('timed out waiting for displays')) + pendingList = null + } + }, 8000) + }) + .catch(reject) + }), + startStream: (displayId: number) => { + transport.start(displayId) + }, + onConnected: (payload) => connectScreen.saveRecent(payload) +} + +const connectScreen = new ConnectScreen(connectEl, deps) +connectScreen.render() diff --git a/src/web/src/transport.ts b/src/web/src/transport.ts new file mode 100644 index 0000000..64a5583 --- /dev/null +++ b/src/web/src/transport.ts @@ -0,0 +1,233 @@ +import type { ClientMessage, ServerMessage, ControlMessage } from './types' +import { hexToBytes } from './util' + +export interface ConnectOptions { + // Full WebTransport endpoint, e.g. https://10.10.1.5:52020/wt + url: string + // SHA-256 fingerprint (hex) of the agent's TLS certificate. + fingerprintHex: string +} + +export interface TransportStats { + bitrate: number // bits/sec + rtt: number // ms (control round-trip estimate) + rxBytes: number // total video bytes received +} + +type MessageHandler = (msg: ControlMessage) => void +type VideoHandler = (chunk: Uint8Array) => void +type StatsHandler = (stats: TransportStats) => void + +/** + * WebTransport client for Distance Desktop. + * + * - One bidirectional stream carries newline-delimited JSON control messages. + * - One unidirectional stream (server -> client) carries raw H264 Annex B video. + * - The server certificate hash is pinned via `serverCertificateHashes` so a + * self-signed agent cert is accepted without OS trust store involvement. + */ +export class Transport { + private wt: WebTransport | null = null + private ctrlWriter: WritableStreamDefaultWriter | null = null + private ctrlReader: ReadableStreamDefaultReader | null = null + private msgHandler: MessageHandler | null = null + private videoHandler: VideoHandler | null = null + private statsHandler: StatsHandler | null = null + + private ctrlBuf = new Uint8Array(0) + private enc = new TextEncoder() + private dec = new TextDecoder() + + // video byte accounting + private rxBytes = 0 + private windowBytes = 0 + private lastWindow = performance.now() + private bitrate = 0 + + // control RTT estimate (time between a request we send and its response) + private pendingSince = 0 + private rtt = 0 + + private statsTimer: number | undefined + private closed = false + + onMessage(h: MessageHandler) { + this.msgHandler = h + } + setVideoHandler(h: VideoHandler) { + this.videoHandler = h + } + onStats(h: StatsHandler) { + this.statsHandler = h + } + + get connected(): boolean { + return this.wt !== null + } + + async connect(opts: ConnectOptions): Promise { + const hash = hexToBytes(opts.fingerprintHex) + if (hash.length !== 32) { + throw new Error('fingerprint must be a 32-byte SHA-256 hex string') + } + + const wt = new WebTransport(opts.url, { + // serverCertificateHashes pins the self-signed agent cert. Older lib.dom + // typings omit it, so we cast loosely; the runtime supports it. + serverCertificateHashes: [{ algorithm: 'sha-256', value: hash }] + } as any) + this.wt = wt + + wt.closed.then(() => { + if (!this.closed) this.msgHandler?.({ type: 'stream-ended' } as ServerMessage) + }) + + await wt.ready + + // Open the control bidi stream (server AcceptStream picks this up). + const bidi = await wt.createBidirectionalStream() + this.ctrlWriter = bidi.writable.getWriter() + this.ctrlReader = bidi.readable.getReader() + this.readControlLoop() + + // Start the video uni-stream reader. + this.readVideoLoop() + + // Stats ticker (bitrate + rtt) once per second. + this.statsTimer = window.setInterval(() => this.tickStats(), 1000) + } + + send(msg: ClientMessage): void { + if (!this.ctrlWriter) throw new Error('not connected') + if (msg.type === 'list-displays' || msg.type === 'start') { + this.pendingSince = performance.now() + } + const bytes = this.enc.encode(JSON.stringify(msg) + '\n') + this.ctrlWriter.write(bytes) + } + + listDisplays(): void { + this.send({ type: 'list-displays' }) + } + + start(displayId: number, opts: { fps?: number; codec?: string; bitrate?: number } = {}): void { + this.send({ type: 'start', display_id: displayId, ...opts }) + } + + stop(): void { + this.send({ type: 'stop' }) + } + + getStats(): TransportStats { + return { bitrate: this.bitrate, rtt: this.rtt, rxBytes: this.rxBytes } + } + + close(): void { + this.closed = true + if (this.statsTimer) window.clearInterval(this.statsTimer) + try { + this.ctrlWriter?.close() + } catch { + /* ignore */ + } + try { + this.wt?.close() + } catch { + /* ignore */ + } + } + + private async readControlLoop(): Promise { + const reader = this.ctrlReader + if (!reader) return + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + if (!value) continue + this.ctrlBuf = concat(this.ctrlBuf, value) + // Split on newline-delimited JSON. + let nl: number + while ((nl = findNL(this.ctrlBuf)) !== -1) { + const line = this.dec.decode(this.ctrlBuf.subarray(0, nl)) + this.ctrlBuf = this.ctrlBuf.subarray(nl + 1) + const text = line.trim() + if (!text) continue + let msg: ControlMessage + try { + msg = JSON.parse(text) + } catch { + continue + } + if ((msg.type === 'displays' || msg.type === 'started') && this.pendingSince) { + this.rtt = Math.round(performance.now() - this.pendingSince) + this.pendingSince = 0 + } + this.msgHandler?.(msg) + } + } + } catch { + /* stream closed */ + } + } + + private async readVideoLoop(): Promise { + const wt = this.wt + if (!wt) return + try { + // Server opens unidirectional stream(s) for video; we read them as they + // arrive on incomingUnidirectionalStreams. + const streamReader = wt.incomingUnidirectionalStreams.getReader() + while (true) { + const { value: recv, done: sdone } = await streamReader.read() + if (sdone) break + if (!recv) continue + const reader = recv.readable.getReader() + while (true) { + const { value, done } = await reader.read() + if (done) break + if (!value) continue + this.rxBytes += value.byteLength + this.windowBytes += value.byteLength + this.videoHandler?.(value) + } + } + } catch { + /* stream closed */ + } + } + + private tickStats(): void { + const now = performance.now() + const dt = (now - this.lastWindow) / 1000 + if (dt > 0) { + this.bitrate = Math.round((this.windowBytes * 8) / dt) + this.windowBytes = 0 + this.lastWindow = now + } + this.statsHandler?.(this.getStats()) + } +} + +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length) + out.set(a, 0) + out.set(b, a.length) + return out +} + +function findNL(buf: Uint8Array): number { + for (let i = 0; i < buf.length; i++) { + if (buf[i] === 0x0a) return i + } + return -1 +} + +/** + * Build the WebTransport URL from a host + port. WebTransport uses the `https` + * scheme and H3, so we always normalize to https://host:port/wt. + */ +export function wtUrl(host: string, port: number): string { + const h = host.replace(/^https?:\/\//, '').replace(/\/$/, '') + return `https://${h}:${port}/wt` +} diff --git a/src/web/src/types.ts b/src/web/src/types.ts new file mode 100644 index 0000000..caa0de8 --- /dev/null +++ b/src/web/src/types.ts @@ -0,0 +1,53 @@ +// Control protocol shared with distancedesktop/agent (src/session.go). +// All control messages are newline-delimited JSON on the bidirectional stream. + +export interface Display { + id: number + width: number + height: number + x: number + y: number + refresh_rate: number +} + +// Client -> Server +export type ClientMessage = + | { type: 'list-displays' } + | { + type: 'start' + display_id: number + fps?: number + codec?: string + bitrate?: number + } + | { type: 'stop' } + // Input messages (server currently acks/ignores; handled by backend work) + | InputMessage + +export type InputMessage = + | { type: 'input'; kind: 'mouse'; dx: number; dy: number; buttons: number } + | { type: 'input'; kind: 'mousedown'; button: number } + | { type: 'input'; kind: 'mouseup'; button: number } + | { type: 'input'; kind: 'wheel'; dx: number; dy: number } + | { type: 'input'; kind: 'key'; code: string; down: boolean } + | { type: 'input'; kind: 'touch'; id: number; x: number; y: number; phase: 'start' | 'move' | 'end' } + +// Server -> Client +export type ServerMessage = + | { type: 'fingerprint-refresh'; algorithm: string; fingerprint: string } + | { type: 'displays'; displays: Display[] } + | { type: 'started'; width: number; height: number; codec: string } + | { type: 'stopped' } + | { type: 'stream-ended' } + | { type: 'error'; message: string } + | { type: 'pong'; t: number } + +export type ControlMessage = ServerMessage + +// Connect payload encoded in a QR / paste blob. +export interface ConnectPayload { + host: string + port?: number + fingerprint: string + label?: string +} diff --git a/src/web/src/ui/connect.ts b/src/web/src/ui/connect.ts new file mode 100644 index 0000000..2db5703 --- /dev/null +++ b/src/web/src/ui/connect.ts @@ -0,0 +1,324 @@ +import type { Display, ConnectPayload } from '../types' +import { el, toast } from '../util' + +const RECENT_KEY = 'distance.recentHosts' + +export interface RecentHost { + host: string + port: number + fingerprint: string + label?: string + last: number +} + +export interface ConnectDeps { + /** Connect + fetch the display list. Resolves with displays or rejects. */ + connectAndList: (payload: ConnectPayload) => Promise + /** Begin streaming a chosen display. */ + startStream: (displayId: number) => void + /** Called once a connection succeeds, to persist the host. */ + onConnected?: (payload: ConnectPayload) => void +} + +/** + * The landing screen: paste/scan the agent fingerprint, pick a recent host, + * then choose a display to stream. Pure DOM (no framework). + */ +export class ConnectScreen { + private root: HTMLElement + private deps: ConnectDeps + private fpInput!: HTMLTextAreaElement + private hostInput!: HTMLInputElement + private portInput!: HTMLInputElement + private labelInput!: HTMLInputElement + private connectBtn!: HTMLButtonElement + private qrStream: MediaStream | null = null + + constructor(root: HTMLElement, deps: ConnectDeps) { + this.root = root + this.deps = deps + } + + render(): void { + this.root.innerHTML = '' + const card = el('div', { class: 'connect-card' }) + + card.append( + el('div', { class: 'brand' }, [ + el('span', { class: 'dot' }), + el('h1', { text: 'Distance Desktop' }) + ]), + el('div', { class: 'subtitle', text: 'Connect to a self-hosted agent over WebTransport.' }) + ) + + // Auto-fill from the serving agent when possible. + const suggested = this.suggestFromPage() + + const fpField = el('div', { class: 'field' }, [el('label', { text: 'Agent fingerprint (SHA-256)' })]) + this.fpInput = el('textarea', { + placeholder: 'paste the fingerprint shown by the agent (64 hex chars)', + spellcheck: 'false' + }) as HTMLTextAreaElement + fpField.append(this.fpInput) + + const row = el('div', { class: 'row' }) + const hostField = el('div', { class: 'field' }, [el('label', { text: 'Host' })]) + this.hostInput = el('input', { + type: 'text', + placeholder: '10.10.1.5', + value: suggested.host + }) as HTMLInputElement + hostField.append(this.hostInput) + + const portField = el('div', { class: 'field' }, [el('label', { text: 'WT port' })]) + this.portInput = el('input', { type: 'number', value: String(suggested.port) }) as HTMLInputElement + portField.append(this.portInput) + row.append(hostField, portField) + + const labelField = el('div', { class: 'field' }, [el('label', { text: 'Label (optional)' })]) + this.labelInput = el('input', { type: 'text', placeholder: 'living room pc' }) as HTMLInputElement + labelField.append(this.labelInput) + + const actions = el('div', { class: 'actions' }) + this.connectBtn = el('button', { class: 'btn primary', text: 'Connect' }) as HTMLButtonElement + this.connectBtn.addEventListener('click', () => this.doConnect()) + const qrBtn = el('button', { class: 'btn ghost', text: 'Scan QR' }) as HTMLButtonElement + qrBtn.addEventListener('click', () => this.openQr()) + actions.append(this.connectBtn, qrBtn) + + const pasteJson = el('div', { class: 'field' }, [ + el('label', { text: '…or paste a connection JSON' }) + ]) + const pasteArea = el('textarea', { + placeholder: '{"host":"10.10.1.5","port":52020,"fingerprint":"abcd…","label":"pc"}', + spellcheck: 'false' + }) as HTMLTextAreaElement + pasteArea.addEventListener('change', () => this.applyJson(pasteArea.value)) + pasteJson.append(pasteArea) + + card.append(fpField, row, labelField, actions, pasteJson) + + // Recent hosts + const recent = this.loadRecent() + if (recent.length) { + const rec = el('div', { class: 'recent' }, [el('h2', { text: 'Recent hosts' })]) + const list = el('div', { class: 'recent-list' }) + for (const h of recent) { + const item = el('div', { class: 'recent-item' }) + const meta = el('div', { class: 'meta' }, [ + el('div', { class: 'host', text: h.label ? `${h.label} · ${h.host}:${h.port}` : `${h.host}:${h.port}` }), + el('div', { class: 'fp', text: h.fingerprint }) + ]) + item.append(meta) + const del = el('button', { class: 'del', title: 'forget', text: '×' }) as HTMLButtonElement + del.addEventListener('click', (e) => { + e.stopPropagation() + this.forget(h.fingerprint + h.host) + this.render() + }) + item.append(del) + item.addEventListener('click', () => { + this.fpInput.value = h.fingerprint + this.hostInput.value = h.host + this.portInput.value = String(h.port) + if (h.label) this.labelInput.value = h.label + }) + list.append(item) + } + rec.append(list) + card.append(rec) + } + + this.root.append(card) + this.fpInput.value = suggested.fingerprint ?? '' + } + + private suggestFromPage(): { host: string; port: number; fingerprint?: string } { + const out = { host: location.hostname || '', port: 52020, fingerprint: undefined as string | undefined } + // The page is served by the agent itself; ask it for its fingerprint + IPs. + fetch('/api/info') + .then((r) => r.json()) + .then((d) => { + if (d?.fingerprint) { + this.fpInput.value = d.fingerprint + } + if (Array.isArray(d?.ips) && d.ips.length && !this.hostInput.value) { + this.hostInput.value = d.ips[0] + } + }) + .catch(() => { + /* not served by an agent / offline */ + }) + return out + } + + private async doConnect(): Promise { + const fingerprint = this.fpInput.value.trim().replace(/\s+/g, '') + const host = this.hostInput.value.trim() + const port = parseInt(this.portInput.value.trim(), 10) || 52020 + const label = this.labelInput.value.trim() || undefined + + if (!/^[0-9a-fA-F]{64}$/.test(fingerprint)) { + toast('Fingerprint must be 64 hex characters', 'err') + return + } + if (!host) { + toast('Enter the agent host', 'err') + return + } + + this.connectBtn.disabled = true + this.connectBtn.textContent = 'Connecting…' + try { + const displays = await this.deps.connectAndList({ host, port, fingerprint, label }) + this.deps.onConnected?.({ host, port, fingerprint, label }) + this.showDisplays(displays, { host, port, fingerprint, label }) + } catch (e) { + toast(`Connection failed: ${(e as Error).message}`, 'err') + this.connectBtn.disabled = false + this.connectBtn.textContent = 'Connect' + } + } + + private showDisplays(displays: Display[], payload: ConnectPayload): void { + this.root.innerHTML = '' + const card = el('div', { class: 'connect-card' }) + card.append( + el('div', { class: 'brand' }, [el('span', { class: 'dot' }), el('h1', { text: 'Choose a display' })]), + el('div', { class: 'subtitle', text: `${payload.host}:${payload.port}` }) + ) + if (!displays.length) { + card.append(el('div', { class: 'muted', text: 'No displays reported by the agent yet.' })) + } + const grid = el('div', { class: 'displays' }) + for (const d of displays) { + const tile = el('button', { class: 'display-tile' }, [ + el('div', { class: 'name', text: `Display ${d.id}` }), + el('div', { class: 'res', text: `${d.width}×${d.height} @ ${d.refresh_rate || '?'}Hz` }) + ]) as HTMLButtonElement + tile.addEventListener('click', () => { + this.deps.startStream(d.id) + this.root.classList.add('hidden') + }) + grid.append(tile) + } + card.append(grid) + this.root.append(card) + } + + private applyJson(text: string): boolean { + let p: ConnectPayload + try { + p = JSON.parse(text.trim()) + } catch { + toast('Invalid connection JSON', 'err') + return false + } + if (p.fingerprint) this.fpInput.value = p.fingerprint + if (p.host) this.hostInput.value = p.host + if (p.port) this.portInput.value = String(p.port) + if (p.label) this.labelInput.value = p.label + toast('Filled from JSON', 'ok') + return true + } + + // ---------- QR scanning ---------- + private openQr(): void { + if (!window.BarcodeDetector) { + toast('QR scanning requires Chrome/Edge with BarcodeDetector', 'err') + return + } + const modal = el('div', { class: 'qr-modal' }) + const box = el('div', { class: 'box' }, [el('h3', { text: 'Scan connection QR' })]) + const video = el('video', { class: 'qr-video', playsinline: 'true' }) as HTMLVideoElement + box.append(video) + box.append(el('div', { class: 'muted', text: 'Point your camera at the agent’s QR code.' })) + const close = el('button', { class: 'btn ghost', text: 'Cancel' }) as HTMLButtonElement + close.addEventListener('click', () => this.stopQr(modal)) + box.append(el('div', { class: 'actions' }, [close])) + modal.append(box) + document.body.append(modal) + + navigator.mediaDevices + .getUserMedia({ video: { facingMode: 'environment' } }) + .then(async (stream) => { + this.qrStream = stream + video.srcObject = stream + await video.play() + this.scanLoop(video, modal) + }) + .catch(() => { + toast('Camera unavailable', 'err') + this.stopQr(modal) + }) + } + + private async scanLoop(video: HTMLVideoElement, modal: HTMLElement): Promise { + if (!this.qrStream || !window.BarcodeDetector) return + const detector = new BarcodeDetector({ formats: ['qr'] }) + const tick = async () => { + if (!this.qrStream) return + try { + const codes = await detector.detect(video) + for (const c of codes) { + if (this.applyJson(c.rawValue)) { + this.stopQr(modal) + return + } + } + } catch { + /* ignore frame errors */ + } + requestAnimationFrame(tick) + } + tick() + } + + private stopQr(modal: HTMLElement): void { + this.qrStream?.getTracks().forEach((t) => t.stop()) + this.qrStream = null + modal.remove() + } + + // ---------- recent hosts ---------- + private loadRecent(): RecentHost[] { + try { + const raw = localStorage.getItem(RECENT_KEY) + if (!raw) return [] + const arr = JSON.parse(raw) as RecentHost[] + return arr.sort((a, b) => b.last - a.last).slice(0, 8) + } catch { + return [] + } + } + + private remember(payload: ConnectPayload): void { + try { + const arr = this.loadRecent().filter((h) => !(h.host === payload.host && h.port === (payload.port ?? 52020))) + arr.unshift({ + host: payload.host, + port: payload.port ?? 52020, + fingerprint: payload.fingerprint, + label: payload.label, + last: Date.now() + }) + localStorage.setItem(RECENT_KEY, JSON.stringify(arr.slice(0, 8))) + } catch { + /* storage may be unavailable */ + } + } + + private forget(key: string): void { + try { + const arr = this.loadRecent().filter((h) => h.fingerprint + h.host !== key) + localStorage.setItem(RECENT_KEY, JSON.stringify(arr)) + } catch { + /* ignore */ + } + } + + /** Persist a host (called by main on successful connect). */ + saveRecent(payload: ConnectPayload): void { + this.remember(payload) + } +} diff --git a/src/web/src/ui/stats.ts b/src/web/src/ui/stats.ts new file mode 100644 index 0000000..952a223 --- /dev/null +++ b/src/web/src/ui/stats.ts @@ -0,0 +1,59 @@ +import { formatBitrate } from '../util' + +export interface StatsView { + fps: number + rtt: number // ms + bitrate: number // bits/sec + width: number + height: number + online: boolean +} + +/** Top-right HUD overlay showing live stream quality metrics. */ +export class StatsOverlay { + private root: HTMLElement + private visible = false + private last: StatsView = { fps: 0, rtt: 0, bitrate: 0, width: 0, height: 0, online: false } + + constructor(root: HTMLElement) { + this.root = root + } + + toggle(): void { + this.visible = !this.visible + this.root.classList.toggle('hidden', !this.visible) + if (this.visible) this.render() + } + + show(): void { + this.visible = true + this.root.classList.remove('hidden') + this.render() + } + + get isVisible(): boolean { + return this.visible + } + + update(v: Partial): void { + this.last = { ...this.last, ...v } + if (this.visible) this.render() + } + + private row(k: string, v: string, cls = ''): string { + return `
${k}${v}
` + } + + private render(): void { + const v = this.last + const rttCls = v.rtt > 150 ? 'crit' : v.rtt > 60 ? 'bad' : '' + const fpsCls = v.fps === 0 ? 'bad' : '' + const bitrateCls = v.bitrate === 0 ? 'bad' : '' + this.root.innerHTML = + this.row('fps', v.fps ? `${v.fps}` : '—', fpsCls) + + this.row('rtt', v.rtt ? `${v.rtt} ms` : '—', rttCls) + + this.row('bitrate', v.bitrate ? formatBitrate(v.bitrate) : '—', bitrateCls) + + this.row('res', v.width ? `${v.width}×${v.height}` : '—') + + this.row('link', v.online ? 'up' : 'down', v.online ? '' : 'crit') + } +} diff --git a/src/web/src/util.ts b/src/web/src/util.ts new file mode 100644 index 0000000..9798d4b --- /dev/null +++ b/src/web/src/util.ts @@ -0,0 +1,61 @@ +// Small DOM + byte helpers shared across modules. + +export function $(sel: string, root: ParentNode = document): HTMLElement { + const el = root.querySelector(sel) + if (!el) throw new Error(`missing element: ${sel}`) + return el as HTMLElement +} + +export function el( + tag: K, + attrs: Partial> = {}, + children: (Node | string)[] = [] +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + for (const [k, v] of Object.entries(attrs)) { + if (v === undefined) continue + if (k === 'class') node.className = v + else if (k === 'text') node.textContent = v + else node.setAttribute(k, v) + } + for (const c of children) node.append(c) + return node +} + +export function hexToBytes(hex: string): Uint8Array { + const clean = hex.replace(/[^0-9a-fA-F]/g, '') + if (clean.length % 2 !== 0) throw new Error('invalid hex length') + const out = new Uint8Array(clean.length / 2) + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(clean.substr(i * 2, 2), 16) + } + return out +} + +export function bytesToHex(b: Uint8Array): string { + let s = '' + for (const v of b) s += v.toString(16).padStart(2, '0') + return s +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B` + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` + return `${(n / 1024 / 1024).toFixed(2)} MB` +} + +export function formatBitrate(bps: number): string { + if (bps < 1000) return `${bps.toFixed(0)} bps` + if (bps < 1_000_000) return `${(bps / 1000).toFixed(0)} kbps` + return `${(bps / 1_000_000).toFixed(2)} Mbps` +} + +let toastTimer: number | undefined +export function toast(msg: string, kind: 'info' | 'err' | 'ok' = 'info', ms = 3200): void { + const t = document.getElementById('toast') + if (!t) return + t.textContent = msg + t.className = `toast ${kind === 'info' ? '' : kind}` + if (toastTimer) window.clearTimeout(toastTimer) + toastTimer = window.setTimeout(() => t.classList.add('hidden'), ms) +} diff --git a/src/web/vite-env.d.ts b/src/web/vite-env.d.ts index 8a47201..176222e 100644 --- a/src/web/vite-env.d.ts +++ b/src/web/vite-env.d.ts @@ -1,57 +1,10 @@ /// -// WebCodecs / WebTransport / BarcodeDetector shims for older lib.dom typings. -// All major evergreen browsers ship these; we only need compile-time types. +// BarcodeDetector (Chrome/Edge) for scanning QR connection payloads. +// WebCodecs (VideoDecoder / EncodedVideoChunk / VideoFrame) and WebTransport +// types are provided by lib.dom in modern TypeScript, so we only shim what is +// missing there. -interface VideoFrame { - readonly timestamp: number - readonly duration: number | null - close(): void -} - -interface EncodedVideoChunkInit { - type: 'key' | 'delta' - timestamp: number - duration?: number - data: BufferSource -} - -declare class EncodedVideoChunk { - constructor(init: EncodedVideoChunkInit) - readonly type: 'key' | 'delta' - readonly timestamp: number - readonly duration: number | null - copyTo(destination: BufferSource): void -} - -interface VideoDecoderConfig { - codec: string - width?: number - height?: number - description?: BufferSource - optimizeForLatency?: boolean -} - -interface VideoDecoderInit { - output: (frame: VideoFrame) => void - error: (error: DOMException) => void -} - -declare class VideoDecoder { - constructor(init: VideoDecoderInit) - readonly state: 'unconfigured' | 'configured' | 'closed' - configure(config: VideoDecoderConfig): void - decode(chunk: EncodedVideoChunk): void - flush(): Promise - reset(): void - close(): void -} - -interface VideoDecoderHeapSizeLimit { - readonly maxEncodedBytes: number -} - -// BarcodeDetector (Chrome/Edge) for QR scanning of connect payloads. interface DetectedBarcode { readonly rawValue: string readonly format: string From 69e630427f3c24d5b45b47e606abaa41f7c5f6c9 Mon Sep 17 00:00:00 2001 From: spacedouut Date: Wed, 26 Aug 2026 11:51:44 -0400 Subject: [PATCH 3/8] spike: restore web app entry index.html (vite build entry) --- src/web/index.html | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/web/index.html diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..cb0f3e0 --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,27 @@ + + + + + + + Distance Desktop + + +
+ +
+ + + + + +
+ + + From 78683fd3c9177cf7a1268770c594aadadd6718bf Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:26:09 +0000 Subject: [PATCH 4/8] fix: apply CodeRabbit auto-fixes Fixed 14 file(s) based on 20 unresolved review comments. Co-authored-by: CodeRabbit --- .gitignore | 4 ++-- AGENTS.md | 19 +++++-------------- src/backend/backend.go | 8 +++++++- src/backend/captured.go | 25 +++++++++++++++++++++++++ src/backend/vnc.go | 3 +++ src/main.go | 24 +++++++++++++++++++++++- src/stream.go | 5 +++-- src/web.go | 11 ++++++++--- src/web/src/decoder.ts | 26 +++++++++++++++++++------- src/web/src/input.ts | 4 ++++ src/web/src/main.ts | 1 + src/web/src/transport.ts | 11 ++++++++--- src/web/src/types.ts | 18 +++++++++--------- src/web/src/ui/connect.ts | 27 +++++++++++++++++++++++++-- 14 files changed, 142 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index f57e578..4befd88 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ env/ # Build directory build/ -# macOS -.DS_STORE +# macOS +.DS_Store # web node modules /src/web/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 00574bc..96c9556 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,8 +17,8 @@ Reads BGRA from captured, GPU-encodes via ffmpeg, publishes over WebTransport/QU | Port | Transport | Purpose | |------|-----------|---------| -| 52020 | UDP | WebTransport — `/wt` for JSON control, `/moq` for MoQ media (same QUIC conn) | -| 52022 | TCP | Web UI (plain HTTP, fingerprint display) | +| 52020 | UDP | WebTransport — `/wt` for JSON control + uni-stream media (QUIC over UDP) | +| 52022 | TCP | Web UI (HTTPS, fingerprint display) | ### Protocol @@ -59,7 +59,7 @@ await transport.ready; const stream = await transport.createBidirectionalStream(); ``` -MoQ has been removed from the agent (was `/moq` + gomoqt). Raw WT uni-stream is the only media path. +**Note**: MoQ integration has been removed. The agent now publishes video exclusively over WebTransport unidirectional streams (raw H.264 Annex B). ### Web UI @@ -86,7 +86,7 @@ Embedded HTML at `http://:52022/` showing: ### Architecture -``` +```text backend.Backend { ListDisplays; StartStream -> Stream <-chan H264Chunk } (src/backend/backend.go) ├─ captured — unix-socket daemon + ffmpeg encode (src/backend/captured.go) ├─ sunshine — Moonlight RTSP passthrough H264 (STUB, src/backend/sunshine.go) @@ -128,19 +128,10 @@ activeBackend (chosen via --backend at startup): 4. Client caches additional fingerprint 5. On next connection, includes both old and new hashes in `serverCertificateHashes` -## MoQ integration - -- `/moq` on same UDP port as `/wt` — separate WebTransport session using gomoqt. -- Each MoQ subscriber gets a `*moqt.TrackWriter` via `PublishFunc("/video", ...)`. -- `publishStream` writes each ffmpeg chunk to all `TrackWriter`s (one MoQ group + one frame per chunk). -- On teardown, `moqBroadcastCancel()` unregisters the publish handler. -- Server TLS `NextProtos` stays `["h3"]` — client MoQ WebTransport negotiates `h3`, `@moq/net` falls back to IETF/moql mode. - ## Dependencies -- `github.com/okdaichi/webtransport-go` — WebTransport over QUIC/HTTP-3 (fork used by gomoqt) +- `github.com/okdaichi/webtransport-go` — WebTransport over QUIC/HTTP-3 - `github.com/quic-go/quic-go` — QUIC transport layer -- `github.com/qumo-dev/gomoqt` — Media over QUIC (MoQ) transport ## Build diff --git a/src/backend/backend.go b/src/backend/backend.go index 694ff4c..5b69b82 100644 --- a/src/backend/backend.go +++ b/src/backend/backend.go @@ -76,7 +76,7 @@ func Get(name string) (Backend, error) { defer regMu.Unlock() b, ok := registry[name] if !ok { - return nil, fmt.Errorf("backend: unknown backend %q (available: %v)", name, Names()) + return nil, fmt.Errorf("backend: unknown backend %q (available: %v)", name, namesSorted()) } return b, nil } @@ -85,6 +85,12 @@ func Get(name string) (Backend, error) { func Names() []string { regMu.Lock() defer regMu.Unlock() + return namesSorted() +} + +// namesSorted returns the sorted list of registered backend names. +// Must be called with regMu held. +func namesSorted() []string { out := make([]string, 0, len(registry)) for n := range registry { out = append(out, n) diff --git a/src/backend/captured.go b/src/backend/captured.go index afe62b1..10bf5be 100644 --- a/src/backend/captured.go +++ b/src/backend/captured.go @@ -114,6 +114,9 @@ func (s *capturedStream) Close() error { } s.closed = true s.cancel() + if s.ffmpeg != nil && s.ffmpeg.Process != nil { + s.ffmpeg.Process.Kill() + } s.stdin.Close() s.ffmpeg.Wait() s.media.Close() @@ -168,6 +171,10 @@ func (b *CapturedBackend) StartStream(ctx context.Context, req StartRequest) (St return nil, fmt.Errorf("media socket: %w", err) } + if deadline, ok := ctx.Deadline(); ok { + media.SetReadDeadline(deadline) + } + var hdr [8]byte if _, err := io.ReadFull(media, hdr[:]); err != nil { ctrl.Close() @@ -176,6 +183,18 @@ func (b *CapturedBackend) StartStream(ctx context.Context, req StartRequest) (St } w := int(binary.BigEndian.Uint32(hdr[0:4])) h := int(binary.BigEndian.Uint32(hdr[4:8])) + const maxDim = 16384 + if w <= 0 || h <= 0 || w > maxDim || h > maxDim { + ctrl.Close() + media.Close() + return nil, fmt.Errorf("first frame: invalid dimensions %dx%d", w, h) + } + const maxPixels = maxDim * maxDim + if int64(w)*int64(h) > maxPixels { + ctrl.Close() + media.Close() + return nil, fmt.Errorf("first frame: dimension overflow %dx%d", w, h) + } log.Printf("captured: first frame %dx%d", w, h) firstFrame := make([]byte, w*h*4) if _, err := io.ReadFull(media, firstFrame); err != nil { @@ -234,12 +253,18 @@ func (b *CapturedBackend) StartStream(ctx context.Context, req StartRequest) (St // BGRA frames -> ffmpeg stdin. go func() { var buf [8]byte + const maxDim = 16384 + const maxPixels = maxDim * maxDim for { if _, err := io.ReadFull(media, buf[:]); err != nil { break } fw := int(binary.BigEndian.Uint32(buf[0:4])) fh := int(binary.BigEndian.Uint32(buf[4:8])) + if fw <= 0 || fh <= 0 || fw > maxDim || fh > maxDim || int64(fw)*int64(fh) > maxPixels { + log.Printf("captured: skipping invalid frame dimensions %dx%d", fw, fh) + break + } frame := make([]byte, fw*fh*4) if _, err := io.ReadFull(media, frame); err != nil { break diff --git a/src/backend/vnc.go b/src/backend/vnc.go index ce3657d..87c47e3 100644 --- a/src/backend/vnc.go +++ b/src/backend/vnc.go @@ -47,6 +47,9 @@ func (b *VNCBackend) ListDisplays(ctx context.Context) ([]Display, error) { return nil, fmt.Errorf("vnc: %w", err) } defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + conn.SetDeadline(deadline) + } banner := make([]byte, 12) n, err := bufio.NewReader(conn).Read(banner) if err != nil { diff --git a/src/main.go b/src/main.go index eff29b4..4fe7133 100644 --- a/src/main.go +++ b/src/main.go @@ -152,7 +152,29 @@ func main() { } wtUpgrader := &webtransport.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + host := r.Host + if origin == "https://"+host || origin == "http://"+host { + return true + } + if cm != nil && *webAddr != "" { + _, webPort, _ := net.SplitHostPort(*webAddr) + hostname, _, _ := net.SplitHostPort(host) + if hostname == "" { + hostname = host + } + allowedWebOrigin := "https://" + net.JoinHostPort(hostname, webPort) + if origin == allowedWebOrigin { + return true + } + } + log.Printf("WT upgrade rejected origin %q from %s", origin, r.RemoteAddr) + return false + }, ApplicationProtocols: []string{"moq-lite-04"}, // kept for legacy client compat } diff --git a/src/stream.go b/src/stream.go index 03e1e3e..e7c8b34 100644 --- a/src/stream.go +++ b/src/stream.go @@ -45,7 +45,9 @@ func startStream(displayID, fps int, codec string, bitrate int, caller *subscrib Codec: codec, Bitrate: bitrate, } - stream, err := b.StartStream(context.Background(), req) + startCtx, startCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer startCancel() + stream, err := b.StartStream(startCtx, req) if err != nil { return fmt.Errorf("%s start-stream: %w", b.Name(), err) } @@ -114,7 +116,6 @@ func teardown() { if sub.video != nil { sub.video.Close() } - sub.sess.CloseWithError(0, "stream ended") } log.Printf("teardown: closed %d subscriber(s)", subCount) ss.subscribers = nil diff --git a/src/web.go b/src/web.go index 8b95c82..0697e59 100644 --- a/src/web.go +++ b/src/web.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "strings" + "time" ) //go:embed web/dist @@ -59,11 +60,15 @@ func startWebUI(addr string, cm *certManager) { tlsCfg := &tls.Config{ GetCertificate: cm.getCertificate, NextProtos: []string{"h2", "http/1.1"}, + MinVersion: tls.VersionTLS12, } srv := &http.Server{ - Addr: addr, - Handler: mux, - TLSConfig: tlsCfg, + Addr: addr, + Handler: mux, + TLSConfig: tlsCfg, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, } log.Printf("Web UI on https://%s", addr) if err := srv.ListenAndServeTLS("", ""); err != nil { diff --git a/src/web/src/decoder.ts b/src/web/src/decoder.ts index dd17e51..fcae337 100644 --- a/src/web/src/decoder.ts +++ b/src/web/src/decoder.ts @@ -81,14 +81,26 @@ export class Decoder { let i = 0 const buf = this.buf while (i + 2 < buf.length) { - if (buf[i] === 0 && buf[i + 1] === 0 && buf[i + 2] === 1) { - if (start < i) { - const nal = buf.subarray(start, i) - this.decodeNal(nal) + if (buf[i] === 0 && buf[i + 1] === 0) { + if (i + 3 < buf.length && buf[i + 2] === 0 && buf[i + 3] === 1) { + // 4-byte start code: 00 00 00 01 + if (start < i) { + const nal = buf.subarray(start, i) + this.decodeNal(nal) + } + start = i + 4 + i = start + continue + } else if (buf[i + 2] === 1) { + // 3-byte start code: 00 00 01 + if (start < i) { + const nal = buf.subarray(start, i) + this.decodeNal(nal) + } + start = i + 3 + i = start + continue } - start = i + 3 - i = start - continue } i++ } diff --git a/src/web/src/input.ts b/src/web/src/input.ts index c8b267e..47ef705 100644 --- a/src/web/src/input.ts +++ b/src/web/src/input.ts @@ -80,6 +80,10 @@ export class InputController { release(): void { if (this.locked && document.exitPointerLock) document.exitPointerLock() + const kb = (navigator as Navigator & { keyboard?: { unlock?: () => void } }).keyboard + if (kb?.unlock) { + kb.unlock() + } } detach(): void { diff --git a/src/web/src/main.ts b/src/web/src/main.ts index 3cd8f3f..09a10c2 100644 --- a/src/web/src/main.ts +++ b/src/web/src/main.ts @@ -60,6 +60,7 @@ function handleMessage(msg: ControlMessage): void { function onStarted(msg: Extract): void { decoder.configure(msg.codec, msg.width, msg.height) + stats.update({ width: msg.width, height: msg.height }) connectEl.classList.add('hidden') viewerEl.classList.remove('hidden') stats.show() diff --git a/src/web/src/transport.ts b/src/web/src/transport.ts index 64a5583..37989ef 100644 --- a/src/web/src/transport.ts +++ b/src/web/src/transport.ts @@ -78,9 +78,14 @@ export class Transport { } as any) this.wt = wt - wt.closed.then(() => { - if (!this.closed) this.msgHandler?.({ type: 'stream-ended' } as ServerMessage) - }) + wt.closed.then( + () => { + if (!this.closed) this.msgHandler?.({ type: 'stream-ended' } as ServerMessage) + }, + () => { + if (!this.closed) this.msgHandler?.({ type: 'stream-ended' } as ServerMessage) + } + ) await wt.ready diff --git a/src/web/src/types.ts b/src/web/src/types.ts index caa0de8..33cfe15 100644 --- a/src/web/src/types.ts +++ b/src/web/src/types.ts @@ -10,6 +10,15 @@ export interface Display { refresh_rate: number } +// Input message types (not yet implemented on server; defined for future use) +export type InputMessage = + | { type: 'input'; kind: 'mouse'; dx: number; dy: number; buttons: number } + | { type: 'input'; kind: 'mousedown'; button: number } + | { type: 'input'; kind: 'mouseup'; button: number } + | { type: 'input'; kind: 'wheel'; dx: number; dy: number } + | { type: 'input'; kind: 'key'; code: string; down: boolean } + | { type: 'input'; kind: 'touch'; id: number; x: number; y: number; phase: 'start' | 'move' | 'end' } + // Client -> Server export type ClientMessage = | { type: 'list-displays' } @@ -21,17 +30,8 @@ export type ClientMessage = bitrate?: number } | { type: 'stop' } - // Input messages (server currently acks/ignores; handled by backend work) | InputMessage -export type InputMessage = - | { type: 'input'; kind: 'mouse'; dx: number; dy: number; buttons: number } - | { type: 'input'; kind: 'mousedown'; button: number } - | { type: 'input'; kind: 'mouseup'; button: number } - | { type: 'input'; kind: 'wheel'; dx: number; dy: number } - | { type: 'input'; kind: 'key'; code: string; down: boolean } - | { type: 'input'; kind: 'touch'; id: number; x: number; y: number; phase: 'start' | 'move' | 'end' } - // Server -> Client export type ServerMessage = | { type: 'fingerprint-refresh'; algorithm: string; fingerprint: string } diff --git a/src/web/src/ui/connect.ts b/src/web/src/ui/connect.ts index 2db5703..c640b3c 100644 --- a/src/web/src/ui/connect.ts +++ b/src/web/src/ui/connect.ts @@ -198,7 +198,6 @@ export class ConnectScreen { ]) as HTMLButtonElement tile.addEventListener('click', () => { this.deps.startStream(d.id) - this.root.classList.add('hidden') }) grid.append(tile) } @@ -207,13 +206,33 @@ export class ConnectScreen { } private applyJson(text: string): boolean { - let p: ConnectPayload + let p: any try { p = JSON.parse(text.trim()) } catch { toast('Invalid connection JSON', 'err') return false } + if (p === null || Array.isArray(p) || typeof p !== 'object') { + toast('Invalid connection JSON: expected object', 'err') + return false + } + if (p.fingerprint && typeof p.fingerprint !== 'string') { + toast('Invalid connection JSON: fingerprint must be string', 'err') + return false + } + if (p.host && typeof p.host !== 'string') { + toast('Invalid connection JSON: host must be string', 'err') + return false + } + if (p.port !== undefined && typeof p.port !== 'number') { + toast('Invalid connection JSON: port must be number', 'err') + return false + } + if (p.label !== undefined && typeof p.label !== 'string') { + toast('Invalid connection JSON: label must be string', 'err') + return false + } if (p.fingerprint) this.fpInput.value = p.fingerprint if (p.host) this.hostInput.value = p.host if (p.port) this.portInput.value = String(p.port) @@ -242,6 +261,10 @@ export class ConnectScreen { navigator.mediaDevices .getUserMedia({ video: { facingMode: 'environment' } }) .then(async (stream) => { + if (!modal.isConnected) { + stream.getTracks().forEach((t) => t.stop()) + return + } this.qrStream = stream video.srcObject = stream await video.play() From c2c7d95e7bc1abee2d70056ee00daeb3e3046037 Mon Sep 17 00:00:00 2001 From: spacedouut Date: Wed, 26 Aug 2026 14:28:19 -0400 Subject: [PATCH 5/8] ci: build agent (go vet/build) and web viewer (npm ci/build) on push/PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No release workflow — just verifies the pluggable backend + web build stay green. --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..06f2d45 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: ci +on: + push: + pull_request: +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache: true + - run: go vet ./... + - run: go build ./... + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: src/web/package-lock.json + - run: npm ci + working-directory: src/web + - run: npm run build + working-directory: src/web From b47bc4bcc8701ee501e9d80e39c35e26965ad014 Mon Sep 17 00:00:00 2001 From: spacedouut Date: Thu, 27 Aug 2026 08:07:26 -0400 Subject: [PATCH 6/8] fix: address CodeRabbit round 2 (no-input batch) --- .github/workflows/ci.yml | 8 ++- AGENTS.md | 3 +- src/backend/captured.go | 24 +++++++- src/backend/sunshine.go | 3 +- src/main.go | 33 +++++------ src/stream.go | 7 +++ ...{index-J6iVdnoc.css => index-BGalDxia.css} | 2 +- src/web/dist/assets/index-CLJ4Wr99.js | 2 - src/web/dist/assets/index-Cq7M16kb.js | 2 + src/web/dist/index.html | 4 +- src/web/src/decoder.ts | 57 ++++++++++++++++--- src/web/src/main.ts | 2 + src/web/src/transport.ts | 9 ++- src/web/src/ui/connect.ts | 2 +- src/web/style.css | 4 +- 15 files changed, 119 insertions(+), 43 deletions(-) rename src/web/dist/assets/{index-J6iVdnoc.css => index-BGalDxia.css} (97%) delete mode 100644 src/web/dist/assets/index-CLJ4Wr99.js create mode 100644 src/web/dist/assets/index-Cq7M16kb.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06f2d45..c3c924b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,17 +2,19 @@ name: ci on: push: pull_request: +permissions: + contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-go@v5 with: go-version: "1.26" cache: true - - run: go vet ./... - - run: go build ./... - uses: actions/setup-node@v4 with: node-version: "22" @@ -22,3 +24,5 @@ jobs: working-directory: src/web - run: npm run build working-directory: src/web + - run: go vet ./... + - run: go build ./... diff --git a/AGENTS.md b/AGENTS.md index 96c9556..5cd1c1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,6 @@ JSON control messages (bidirectional stream): {"type":"fingerprint-refresh","algorithm":"sha-256","fingerprint":""} // sent on connect + cert rotation ``` -**Endpoint**: `https://:52020/wt` (WebTransport, QUIC over UDP) ### Cert system - Self-signed ECDSA P-256 certificate, 13-day validity @@ -87,7 +86,7 @@ Embedded HTML at `http://:52022/` showing: ### Architecture ```text -backend.Backend { ListDisplays; StartStream -> Stream <-chan H264Chunk } (src/backend/backend.go) +backend.Backend { ListDisplays(ctx) ([]Display,error); StartStream(ctx, StartRequest) (Stream,error); Stream.Chunks() <-chan H264Chunk } (src/backend/backend.go) ├─ captured — unix-socket daemon + ffmpeg encode (src/backend/captured.go) ├─ sunshine — Moonlight RTSP passthrough H264 (STUB, src/backend/sunshine.go) ├─ vnc — RFB frame polling (STUB, src/backend/vnc.go) diff --git a/src/backend/captured.go b/src/backend/captured.go index 10bf5be..7da7c8c 100644 --- a/src/backend/captured.go +++ b/src/backend/captured.go @@ -23,6 +23,7 @@ import ( "strconv" "strings" "sync" + "time" ) func init() { Register(&CapturedBackend{}) } @@ -64,9 +65,15 @@ func (b *CapturedBackend) ListDisplays(ctx context.Context) ([]Display, error) { dec := json.NewDecoder(conn) log.Printf("captured: sending list-displays") + if deadline, ok := ctx.Deadline(); ok { + conn.SetDeadline(deadline) + } if err := enc.Encode(map[string]string{"type": "list-displays"}); err != nil { return nil, err } + if deadline, ok := ctx.Deadline(); ok { + conn.SetDeadline(deadline) + } var resp capturedDisplayResp if err := dec.Decode(&resp); err != nil { log.Printf("captured: list-displays decode error: %v", err) @@ -115,10 +122,19 @@ func (s *capturedStream) Close() error { s.closed = true s.cancel() if s.ffmpeg != nil && s.ffmpeg.Process != nil { - s.ffmpeg.Process.Kill() + _ = s.ffmpeg.Process.Kill() + } + if s.stdin != nil { + s.stdin.Close() + } + if s.ffmpeg != nil { + done := make(chan struct{}) + go func() { s.ffmpeg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + } } - s.stdin.Close() - s.ffmpeg.Wait() s.media.Close() json.NewEncoder(s.ctrl).Encode(map[string]string{"type": "stop-stream"}) s.ctrl.Close() @@ -196,6 +212,8 @@ func (b *CapturedBackend) StartStream(ctx context.Context, req StartRequest) (St return nil, fmt.Errorf("first frame: dimension overflow %dx%d", w, h) } log.Printf("captured: first frame %dx%d", w, h) + // handshake done — clear deadline for steady-state streaming reads + media.SetReadDeadline(time.Time{}) firstFrame := make([]byte, w*h*4) if _, err := io.ReadFull(media, firstFrame); err != nil { ctrl.Close() diff --git a/src/backend/sunshine.go b/src/backend/sunshine.go index aebfe80..5914d6b 100644 --- a/src/backend/sunshine.go +++ b/src/backend/sunshine.go @@ -29,7 +29,6 @@ import ( "log" "net" "strconv" - "strings" ) func init() { Register(&SunshineBackend{}) } @@ -97,7 +96,7 @@ func tcpProbe(ctx context.Context, addr string, defPort int) error { return fmt.Errorf("no address configured") } target := addr - if !strings.Contains(addr, ":") || strings.Count(addr, ":") == 0 { + if _, _, err := net.SplitHostPort(addr); err != nil { target = net.JoinHostPort(addr, strconv.Itoa(defPort)) } var d net.Dialer diff --git a/src/main.go b/src/main.go index 4fe7133..eeaaba8 100644 --- a/src/main.go +++ b/src/main.go @@ -45,34 +45,35 @@ func parseKV(s string) map[string]string { // configureBackends builds the concrete backends from flags. func configureBackends(o backendOpts) { - reg := func(name, opts string, apply func(map[string]string)) { - if opts == "" { - return + for _, entry := range []struct { + name string + opts string + }{ + {"sunshine", o.sunshine}, + {"vnc", o.vnc}, + {"rdp", o.rdp}, + } { + if entry.opts == "" { + continue } - b, err := backend.Get(name) + b, err := backend.Get(entry.name) if err != nil { log.Fatalf("%v", err) } + addr := parseKV(entry.opts)["addr"] switch t := b.(type) { case *backend.SunshineBackend: - t.Addr = parseKV(opts)["addr"] + t.Addr = addr case *backend.VNCBackend: - t.Addr = parseKV(opts)["addr"] + t.Addr = addr case *backend.RDPBackend: - t.Addr = parseKV(opts)["addr"] - default: - apply(parseKV(opts)) + t.Addr = addr } } - applyCaptured := func(kv map[string]string) {} // source=/device= handled inside captured pipeline - reg("sunshine", o.sunshine, applyCaptured) - reg("vnc", o.vnc, applyCaptured) - reg("rdp", o.rdp, applyCaptured) - _ = o.captured // captured config (source/device) consumed by Spike B pipeline } // selectBackend resolves the requested backend, probing candidates in auto order. -func selectBackend(name string, dryRun bool) backend.Backend { +func selectBackend(name string) backend.Backend { if name == "" || name == "auto" { for _, cand := range backend.Candidates() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) @@ -110,7 +111,7 @@ func main() { flag.Parse() configureBackends(bopts) - sel := selectBackend(*backendName, *dryRun) + sel := selectBackend(*backendName) if *dryRun { displays, err := sel.ListDisplays(context.Background()) diff --git a/src/stream.go b/src/stream.go index e7c8b34..381f70a 100644 --- a/src/stream.go +++ b/src/stream.go @@ -78,6 +78,13 @@ func startStream(displayID, fps int, codec string, bitrate int, caller *subscrib // publishStream fans backend chunks out to all subscribers. func publishStream(ctx context.Context, ss *streamState) { + defer func() { + stateMu.Lock() + if state == ss { + teardown() + } + stateMu.Unlock() + }() for chunk := range ss.stream.Chunks() { if ctx.Err() != nil { return diff --git a/src/web/dist/assets/index-J6iVdnoc.css b/src/web/dist/assets/index-BGalDxia.css similarity index 97% rename from src/web/dist/assets/index-J6iVdnoc.css rename to src/web/dist/assets/index-BGalDxia.css index ffbe0b0..ddc04b4 100644 --- a/src/web/dist/assets/index-J6iVdnoc.css +++ b/src/web/dist/assets/index-BGalDxia.css @@ -1 +1 @@ -:root{--bg: #0c0d10;--bg-elev: #15171c;--bg-elev-2: #1d2026;--border: #2a2e37;--border-strong: #3a404c;--text: #e7e9ee;--text-dim: #9aa1ad;--text-faint: #6b7280;--accent: #5b8cff;--accent-2: #3df0a8;--danger: #ff5c6c;--warn: #ffcf5c;--mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, monospace;--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;--radius: 12px}*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}body{font-family:var(--sans);background:radial-gradient(1200px 800px at 70% -10%,#161a22 0%,var(--bg) 60%);color:var(--text);overflow:hidden;-webkit-font-smoothing:antialiased}#app{position:fixed;inset:0}.screen{position:absolute;inset:0}.hidden{display:none!important}#connect{display:flex;align-items:center;justify-content:center;padding:24px;overflow:auto}.connect-card{width:min(560px,100%);background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius);padding:28px;box-shadow:0 20px 60px #00000073}.brand{display:flex;align-items:center;gap:12px;margin-bottom:4px}.brand .dot{width:12px;height:12px;border-radius:50%;background:var(--accent-2);box-shadow:0 0 12px var(--accent-2)}.brand h1{font-size:1.25rem;font-weight:650;letter-spacing:.2px}.subtitle{color:var(--text-dim);font-size:.85rem;margin-bottom:22px}.field{margin-bottom:16px}.field>label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);margin-bottom:6px}input[type=text],input[type=number],textarea{width:100%;background:var(--bg-elev-2);border:1px solid var(--border);border-radius:9px;color:var(--text);font-family:var(--mono);font-size:.85rem;padding:10px 12px;outline:none;transition:border-color .15s,box-shadow .15s}input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px #5b8cff2e}textarea{resize:vertical;min-height:64px;word-break:break-all;line-height:1.5}.row{display:flex;gap:12px}.row>.field{flex:1}.btn{appearance:none;border:1px solid var(--border-strong);background:var(--bg-elev-2);color:var(--text);font-size:.85rem;font-weight:550;padding:10px 14px;border-radius:9px;cursor:pointer;transition:background .15s,border-color .15s,transform .05s}.btn:hover{border-color:var(--accent)}.btn:active{transform:translateY(1px)}.btn.primary{background:linear-gradient(180deg,#5b8cff,#3f6fe0);border-color:#3f6fe0;color:#fff}.btn.primary:hover{filter:brightness(1.06)}.btn.ghost{background:transparent}.btn:disabled{opacity:.5;cursor:not-allowed}.actions{display:flex;gap:10px;margin-top:6px}.actions .btn.primary{flex:1}.recent{margin-top:22px;border-top:1px solid var(--border);padding-top:16px}.recent h2{font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);margin-bottom:10px}.recent-list{display:flex;flex-direction:column;gap:8px}.recent-item{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-elev-2);border:1px solid var(--border);border-radius:9px;cursor:pointer}.recent-item:hover{border-color:var(--accent)}.recent-item .meta{flex:1;min-width:0}.recent-item .host{font-family:var(--mono);font-size:.82rem}.recent-item .fp{font-family:var(--mono);font-size:.68rem;color:var(--text-faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.recent-item .del{color:var(--text-faint);border:none;background:none;cursor:pointer;font-size:1rem;padding:2px 6px}.recent-item .del:hover{color:var(--danger)}.displays{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:14px}.display-tile{background:var(--bg-elev-2);border:1px solid var(--border);border-radius:10px;padding:14px;cursor:pointer;text-align:left}.display-tile:hover{border-color:var(--accent)}.display-tile .name{font-weight:600;font-size:.9rem}.display-tile .res{color:var(--text-dim);font-size:.78rem;margin-top:4px;font-family:var(--mono)}#viewer{background:#000}#stage{width:100%;height:100%;display:block;object-fit:contain;cursor:none;background:#000}#stage:not(.locked){cursor:default}.viewer-hint{position:absolute;left:50%;bottom:18px;transform:translate(-50%);background:#0000008c;border:1px solid var(--border);color:var(--text-dim);font-size:.76rem;padding:7px 12px;border-radius:999px;backdrop-filter:blur(6px);pointer-events:none;transition:opacity .3s}.viewer-hint kbd{font-family:var(--mono);background:var(--bg-elev-2);border:1px solid var(--border-strong);border-radius:5px;padding:1px 5px;font-size:.72rem}.stats{position:absolute;top:12px;right:12px;min-width:150px;background:#0c0d10b8;border:1px solid var(--border);border-radius:10px;padding:10px 12px;font-family:var(--mono);font-size:.78rem;backdrop-filter:blur(8px);user-select:none}.stats .stat{display:flex;justify-content:space-between;gap:16px;padding:2px 0}.stats .stat .k{color:var(--text-faint)}.stats .stat .v{color:var(--accent-2)}.stats .stat .v.bad{color:var(--warn)}.stats .stat .v.crit{color:var(--danger)}.toast{position:absolute;left:50%;top:16px;transform:translate(-50%);background:var(--bg-elev-2);border:1px solid var(--border-strong);color:var(--text);padding:10px 16px;border-radius:10px;font-size:.85rem;box-shadow:0 12px 40px #00000080;z-index:50;max-width:80%}.toast.err{border-color:var(--danger);color:#ffd9dd}.toast.ok{border-color:var(--accent-2)}.qr-video{width:100%;border-radius:10px;background:#000;display:block;margin-top:10px}.qr-modal{position:fixed;inset:0;background:#000000b3;display:flex;align-items:center;justify-content:center;z-index:100;padding:24px}.qr-modal .box{width:min(420px,100%);background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius);padding:20px}.qr-modal .box h3{margin-bottom:10px;font-size:1rem}.muted{color:var(--text-faint);font-size:.76rem;margin-top:8px;line-height:1.5} +:root{--bg: #0c0d10;--bg-elev: #15171c;--bg-elev-2: #1d2026;--border: #2a2e37;--border-strong: #3a404c;--text: #e7e9ee;--text-dim: #9aa1ad;--text-faint: #6b7280;--accent: #5b8cff;--accent-2: #3df0a8;--danger: #ff5c6c;--warn: #ffcf5c;--mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", menlo, monospace;--sans: system-ui, -apple-system, "Segoe UI", roboto, helvetica, arial, sans-serif;--radius: 12px}*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%}body{font-family:var(--sans);background:radial-gradient(1200px 800px at 70% -10%,#161a22 0%,var(--bg) 60%);color:var(--text);overflow:hidden;-webkit-font-smoothing:antialiased}#app{position:fixed;inset:0}.screen{position:absolute;inset:0}.hidden{display:none!important}#connect{display:flex;align-items:center;justify-content:center;padding:24px;overflow:auto}.connect-card{width:min(560px,100%);background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius);padding:28px;box-shadow:0 20px 60px #00000073}.brand{display:flex;align-items:center;gap:12px;margin-bottom:4px}.brand .dot{width:12px;height:12px;border-radius:50%;background:var(--accent-2);box-shadow:0 0 12px var(--accent-2)}.brand h1{font-size:1.25rem;font-weight:650;letter-spacing:.2px}.subtitle{color:var(--text-dim);font-size:.85rem;margin-bottom:22px}.field{margin-bottom:16px}.field>label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);margin-bottom:6px}input[type=text],input[type=number],textarea{width:100%;background:var(--bg-elev-2);border:1px solid var(--border);border-radius:9px;color:var(--text);font-family:var(--mono);font-size:.85rem;padding:10px 12px;outline:none;transition:border-color .15s,box-shadow .15s}input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px #5b8cff2e}textarea{resize:vertical;min-height:64px;word-break:break-all;line-height:1.5}.row{display:flex;gap:12px}.row>.field{flex:1}.btn{appearance:none;border:1px solid var(--border-strong);background:var(--bg-elev-2);color:var(--text);font-size:.85rem;font-weight:550;padding:10px 14px;border-radius:9px;cursor:pointer;transition:background .15s,border-color .15s,transform .05s}.btn:hover{border-color:var(--accent)}.btn:active{transform:translateY(1px)}.btn.primary{background:linear-gradient(180deg,#5b8cff,#3f6fe0);border-color:#3f6fe0;color:#fff}.btn.primary:hover{filter:brightness(1.06)}.btn.ghost{background:transparent}.btn:disabled{opacity:.5;cursor:not-allowed}.actions{display:flex;gap:10px;margin-top:6px}.actions .btn.primary{flex:1}.recent{margin-top:22px;border-top:1px solid var(--border);padding-top:16px}.recent h2{font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);margin-bottom:10px}.recent-list{display:flex;flex-direction:column;gap:8px}.recent-item{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-elev-2);border:1px solid var(--border);border-radius:9px;cursor:pointer}.recent-item:hover{border-color:var(--accent)}.recent-item .meta{flex:1;min-width:0}.recent-item .host{font-family:var(--mono);font-size:.82rem}.recent-item .fp{font-family:var(--mono);font-size:.68rem;color:var(--text-faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.recent-item .del{color:var(--text-faint);border:none;background:none;cursor:pointer;font-size:1rem;padding:2px 6px}.recent-item .del:hover{color:var(--danger)}.displays{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:14px}.display-tile{background:var(--bg-elev-2);border:1px solid var(--border);border-radius:10px;padding:14px;cursor:pointer;text-align:left}.display-tile:hover{border-color:var(--accent)}.display-tile .name{font-weight:600;font-size:.9rem}.display-tile .res{color:var(--text-dim);font-size:.78rem;margin-top:4px;font-family:var(--mono)}#viewer{background:#000}#stage{width:100%;height:100%;display:block;object-fit:contain;cursor:none;background:#000}#stage:not(.locked){cursor:default}.viewer-hint{position:absolute;left:50%;bottom:18px;transform:translate(-50%);background:#0000008c;border:1px solid var(--border);color:var(--text-dim);font-size:.76rem;padding:7px 12px;border-radius:999px;backdrop-filter:blur(6px);pointer-events:none;transition:opacity .3s}.viewer-hint kbd{font-family:var(--mono);background:var(--bg-elev-2);border:1px solid var(--border-strong);border-radius:5px;padding:1px 5px;font-size:.72rem}.stats{position:absolute;top:12px;right:12px;min-width:150px;background:#0c0d10b8;border:1px solid var(--border);border-radius:10px;padding:10px 12px;font-family:var(--mono);font-size:.78rem;backdrop-filter:blur(8px);user-select:none}.stats .stat{display:flex;justify-content:space-between;gap:16px;padding:2px 0}.stats .stat .k{color:var(--text-faint)}.stats .stat .v{color:var(--accent-2)}.stats .stat .v.bad{color:var(--warn)}.stats .stat .v.crit{color:var(--danger)}.toast{position:absolute;left:50%;top:16px;transform:translate(-50%);background:var(--bg-elev-2);border:1px solid var(--border-strong);color:var(--text);padding:10px 16px;border-radius:10px;font-size:.85rem;box-shadow:0 12px 40px #00000080;z-index:50;max-width:80%}.toast.err{border-color:var(--danger);color:#ffd9dd}.toast.ok{border-color:var(--accent-2)}.qr-video{width:100%;border-radius:10px;background:#000;display:block;margin-top:10px}.qr-modal{position:fixed;inset:0;background:#000000b3;display:flex;align-items:center;justify-content:center;z-index:100;padding:24px}.qr-modal .box{width:min(420px,100%);background:var(--bg-elev);border:1px solid var(--border);border-radius:var(--radius);padding:20px}.qr-modal .box h3{margin-bottom:10px;font-size:1rem}.muted{color:var(--text-faint);font-size:.76rem;margin-top:8px;line-height:1.5} diff --git a/src/web/dist/assets/index-CLJ4Wr99.js b/src/web/dist/assets/index-CLJ4Wr99.js deleted file mode 100644 index 4e15d42..0000000 --- a/src/web/dist/assets/index-CLJ4Wr99.js +++ /dev/null @@ -1,2 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))s(n);new MutationObserver(n=>{for(const o of n)if(o.type==="childList")for(const a of o.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&s(a)}).observe(document,{childList:!0,subtree:!0});function e(n){const o={};return n.integrity&&(o.integrity=n.integrity),n.referrerPolicy&&(o.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?o.credentials="include":n.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function s(n){if(n.ep)return;n.ep=!0;const o=e(n);fetch(n.href,o)}})();function r(i,t={},e=[]){const s=document.createElement(i);for(const[n,o]of Object.entries(t))o!==void 0&&(n==="class"?s.className=o:n==="text"?s.textContent=o:s.setAttribute(n,o));for(const n of e)s.append(n);return s}function H(i){const t=i.replace(/[^0-9a-fA-F]/g,"");if(t.length%2!==0)throw new Error("invalid hex length");const e=new Uint8Array(t.length/2);for(let s=0;ss.classList.add("hidden"),e))}class N{wt=null;ctrlWriter=null;ctrlReader=null;msgHandler=null;videoHandler=null;statsHandler=null;ctrlBuf=new Uint8Array(0);enc=new TextEncoder;dec=new TextDecoder;rxBytes=0;windowBytes=0;lastWindow=performance.now();bitrate=0;pendingSince=0;rtt=0;statsTimer;closed=!1;onMessage(t){this.msgHandler=t}setVideoHandler(t){this.videoHandler=t}onStats(t){this.statsHandler=t}get connected(){return this.wt!==null}async connect(t){const e=H(t.fingerprintHex);if(e.length!==32)throw new Error("fingerprint must be a 32-byte SHA-256 hex string");const s=new WebTransport(t.url,{serverCertificateHashes:[{algorithm:"sha-256",value:e}]});this.wt=s,s.closed.then(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})}),await s.ready;const n=await s.createBidirectionalStream();this.ctrlWriter=n.writable.getWriter(),this.ctrlReader=n.readable.getReader(),this.readControlLoop(),this.readVideoLoop(),this.statsTimer=window.setInterval(()=>this.tickStats(),1e3)}send(t){if(!this.ctrlWriter)throw new Error("not connected");(t.type==="list-displays"||t.type==="start")&&(this.pendingSince=performance.now());const e=this.enc.encode(JSON.stringify(t)+` -`);this.ctrlWriter.write(e)}listDisplays(){this.send({type:"list-displays"})}start(t,e={}){this.send({type:"start",display_id:t,...e})}stop(){this.send({type:"stop"})}getStats(){return{bitrate:this.bitrate,rtt:this.rtt,rxBytes:this.rxBytes}}close(){this.closed=!0,this.statsTimer&&window.clearInterval(this.statsTimer);try{this.ctrlWriter?.close()}catch{}try{this.wt?.close()}catch{}}async readControlLoop(){const t=this.ctrlReader;if(t)try{for(;;){const{value:e,done:s}=await t.read();if(s)break;if(!e)continue;this.ctrlBuf=O(this.ctrlBuf,e);let n;for(;(n=W(this.ctrlBuf))!==-1;){const o=this.dec.decode(this.ctrlBuf.subarray(0,n));this.ctrlBuf=this.ctrlBuf.subarray(n+1);const a=o.trim();if(!a)continue;let l;try{l=JSON.parse(a)}catch{continue}(l.type==="displays"||l.type==="started")&&this.pendingSince&&(this.rtt=Math.round(performance.now()-this.pendingSince),this.pendingSince=0),this.msgHandler?.(l)}}}catch{}}async readVideoLoop(){const t=this.wt;if(t)try{const e=t.incomingUnidirectionalStreams.getReader();for(;;){const{value:s,done:n}=await e.read();if(n)break;if(!s)continue;const o=s.readable.getReader();for(;;){const{value:a,done:l}=await o.read();if(l)break;a&&(this.rxBytes+=a.byteLength,this.windowBytes+=a.byteLength,this.videoHandler?.(a))}}}catch{}}tickStats(){const t=performance.now(),e=(t-this.lastWindow)/1e3;e>0&&(this.bitrate=Math.round(this.windowBytes*8/e),this.windowBytes=0,this.lastWindow=t),this.statsHandler?.(this.getStats())}}function O(i,t){const e=new Uint8Array(i.length+t.length);return e.set(i,0),e.set(t,i.length),e}function W(i){for(let t=0;t"u")throw new Error("WebCodecs VideoDecoder is not available in this browser")}configure(t,e,s){if(this.videoDecoder&&this.videoDecoder.state!=="closed")try{this.videoDecoder.reset()}catch{}this.videoDecoder=new VideoDecoder({output:n=>this.onFrame(n),error:n=>console.error("[decoder] error",n)}),this.videoDecoder.configure(M(t,e,s))}onFrame(t){const e=t.displayWidth,s=t.displayHeight;(this.canvas.width!==e||this.canvas.height!==s)&&(this.canvas.width=e,this.canvas.height=s),this.ctx.drawImage(t,0,0,e,s),t.close(),this.frameCount++,this.firstFrameDrawn||(this.firstFrameDrawn=!0,this.onFirstFrame?.())}feed(t){if(!this.videoDecoder||this.videoDecoder.state!=="configured")return;this.buf=P(this.buf,t);let e=0,s=0;const n=this.buf;for(;s+2=1&&(this.fps=Math.round(this.frameCount/e),this.frameCount=0,this.fpsWindowStart=t,this.onFps?.(this.fps))}get currentFps(){return this.fps}reset(){this.buf=new Uint8Array(0),this.dts=0;try{this.videoDecoder?.reset()}catch{}}close(){try{this.videoDecoder?.close()}catch{}this.videoDecoder=null}}function P(i,t){const e=new Uint8Array(i.length+t.length);return e.set(i,0),e.set(t,i.length),e}class U{send;target=null;locked=!1;bound=[];constructor(t){this.send=t}attach(t){this.target=t,this.on(t,"click",()=>{!this.locked&&t.requestPointerLock&&t.requestPointerLock()}),this.on(document,"pointerlockchange",()=>{this.locked=document.pointerLockElement===t,t.classList.toggle("locked",this.locked),this.locked&&this.tryKeyboardLock()}),this.on(t,"mousemove",e=>{if(!this.locked)return;const s=e;this.send({type:"input",kind:"mouse",dx:s.movementX,dy:s.movementY,buttons:s.buttons})}),this.on(t,"mousedown",e=>{const s=e;this.send({type:"input",kind:"mousedown",button:s.button})}),this.on(t,"mouseup",e=>{const s=e;this.send({type:"input",kind:"mouseup",button:s.button})}),this.on(t,"wheel",e=>{if(!this.locked)return;e.preventDefault();const s=e;this.send({type:"input",kind:"wheel",dx:s.deltaX,dy:s.deltaY})},{passive:!1}),this.on(t,"contextmenu",e=>e.preventDefault()),this.on(window,"keydown",e=>this.onKey(e,!0)),this.on(window,"keyup",e=>this.onKey(e,!1)),this.on(t,"touchstart",e=>this.onTouch(e,"start"),{passive:!1}),this.on(t,"touchmove",e=>this.onTouch(e,"move"),{passive:!1}),this.on(t,"touchend",e=>this.onTouch(e,"end"),{passive:!1})}get isLocked(){return this.locked}release(){this.locked&&document.exitPointerLock&&document.exitPointerLock()}detach(){this.release();for(const[t,e,s,n]of this.bound)t.removeEventListener(e,s,n);this.bound=[],this.target=null}on(t,e,s,n){t.addEventListener(e,s,n),this.bound.push([t,e,s,n??!1])}tryKeyboardLock(){const t=navigator.keyboard;t?.lock&&t.lock(["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"]).catch(()=>{})}onKey(t,e){if(!this.locked)return;["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","'","/","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"].includes(t.key)&&t.preventDefault(),this.send({type:"input",kind:"key",code:t.code,down:e})}onTouch(t,e){if(!this.target)return;t.preventDefault();const s=this.target.getBoundingClientRect(),n=t.changedTouches;for(let o=0;othis.doConnect());const F=r("button",{class:"btn ghost",text:"Scan QR"});F.addEventListener("click",()=>this.openQr()),w.append(this.connectBtn,F);const S=r("div",{class:"field"},[r("label",{text:"…or paste a connection JSON"})]),g=r("textarea",{placeholder:'{"host":"10.10.1.5","port":52020,"fingerprint":"abcd…","label":"pc"}',spellcheck:"false"});g.addEventListener("change",()=>this.applyJson(g.value)),S.append(g),t.append(s,n,l,w,S);const L=this.loadRecent();if(L.length){const I=r("div",{class:"recent"},[r("h2",{text:"Recent hosts"})]),E=r("div",{class:"recent-list"});for(const c of L){const v=r("div",{class:"recent-item"}),T=r("div",{class:"meta"},[r("div",{class:"host",text:c.label?`${c.label} · ${c.host}:${c.port}`:`${c.host}:${c.port}`}),r("div",{class:"fp",text:c.fingerprint})]);v.append(T);const D=r("button",{class:"del",title:"forget",text:"×"});D.addEventListener("click",A=>{A.stopPropagation(),this.forget(c.fingerprint+c.host),this.render()}),v.append(D),v.addEventListener("click",()=>{this.fpInput.value=c.fingerprint,this.hostInput.value=c.host,this.portInput.value=String(c.port),c.label&&(this.labelInput.value=c.label)}),E.append(v)}I.append(E),t.append(I)}this.root.append(t),this.fpInput.value=e.fingerprint??""}suggestFromPage(){const t={host:location.hostname||"",port:52020,fingerprint:void 0};return fetch("/api/info").then(e=>e.json()).then(e=>{e?.fingerprint&&(this.fpInput.value=e.fingerprint),Array.isArray(e?.ips)&&e.ips.length&&!this.hostInput.value&&(this.hostInput.value=e.ips[0])}).catch(()=>{}),t}async doConnect(){const t=this.fpInput.value.trim().replace(/\s+/g,""),e=this.hostInput.value.trim(),s=parseInt(this.portInput.value.trim(),10)||52020,n=this.labelInput.value.trim()||void 0;if(!/^[0-9a-fA-F]{64}$/.test(t)){d("Fingerprint must be 64 hex characters","err");return}if(!e){d("Enter the agent host","err");return}this.connectBtn.disabled=!0,this.connectBtn.textContent="Connecting…";try{const o=await this.deps.connectAndList({host:e,port:s,fingerprint:t,label:n});this.deps.onConnected?.({host:e,port:s,fingerprint:t,label:n}),this.showDisplays(o,{host:e,port:s,fingerprint:t,label:n})}catch(o){d(`Connection failed: ${o.message}`,"err"),this.connectBtn.disabled=!1,this.connectBtn.textContent="Connect"}}showDisplays(t,e){this.root.innerHTML="";const s=r("div",{class:"connect-card"});s.append(r("div",{class:"brand"},[r("span",{class:"dot"}),r("h1",{text:"Choose a display"})]),r("div",{class:"subtitle",text:`${e.host}:${e.port}`})),t.length||s.append(r("div",{class:"muted",text:"No displays reported by the agent yet."}));const n=r("div",{class:"displays"});for(const o of t){const a=r("button",{class:"display-tile"},[r("div",{class:"name",text:`Display ${o.id}`}),r("div",{class:"res",text:`${o.width}×${o.height} @ ${o.refresh_rate||"?"}Hz`})]);a.addEventListener("click",()=>{this.deps.startStream(o.id),this.root.classList.add("hidden")}),n.append(a)}s.append(n),this.root.append(s)}applyJson(t){let e;try{e=JSON.parse(t.trim())}catch{return d("Invalid connection JSON","err"),!1}return e.fingerprint&&(this.fpInput.value=e.fingerprint),e.host&&(this.hostInput.value=e.host),e.port&&(this.portInput.value=String(e.port)),e.label&&(this.labelInput.value=e.label),d("Filled from JSON","ok"),!0}openQr(){if(!window.BarcodeDetector){d("QR scanning requires Chrome/Edge with BarcodeDetector","err");return}const t=r("div",{class:"qr-modal"}),e=r("div",{class:"box"},[r("h3",{text:"Scan connection QR"})]),s=r("video",{class:"qr-video",playsinline:"true"});e.append(s),e.append(r("div",{class:"muted",text:"Point your camera at the agent’s QR code."}));const n=r("button",{class:"btn ghost",text:"Cancel"});n.addEventListener("click",()=>this.stopQr(t)),e.append(r("div",{class:"actions"},[n])),t.append(e),document.body.append(t),navigator.mediaDevices.getUserMedia({video:{facingMode:"environment"}}).then(async o=>{this.qrStream=o,s.srcObject=o,await s.play(),this.scanLoop(s,t)}).catch(()=>{d("Camera unavailable","err"),this.stopQr(t)})}async scanLoop(t,e){if(!this.qrStream||!window.BarcodeDetector)return;const s=new BarcodeDetector({formats:["qr"]}),n=async()=>{if(this.qrStream){try{const o=await s.detect(t);for(const a of o)if(this.applyJson(a.rawValue)){this.stopQr(e);return}}catch{}requestAnimationFrame(n)}};n()}stopQr(t){this.qrStream?.getTracks().forEach(e=>e.stop()),this.qrStream=null,t.remove()}loadRecent(){try{const t=localStorage.getItem(y);return t?JSON.parse(t).sort((s,n)=>n.last-s.last).slice(0,8):[]}catch{return[]}}remember(t){try{const e=this.loadRecent().filter(s=>!(s.host===t.host&&s.port===(t.port??52020)));e.unshift({host:t.host,port:t.port??52020,fingerprint:t.fingerprint,label:t.label,last:Date.now()}),localStorage.setItem(y,JSON.stringify(e.slice(0,8)))}catch{}}forget(t){try{const e=this.loadRecent().filter(s=>s.fingerprint+s.host!==t);localStorage.setItem(y,JSON.stringify(e))}catch{}}saveRecent(t){this.remember(t)}}class V{root;visible=!1;last={fps:0,rtt:0,bitrate:0,width:0,height:0,online:!1};constructor(t){this.root=t}toggle(){this.visible=!this.visible,this.root.classList.toggle("hidden",!this.visible),this.visible&&this.render()}show(){this.visible=!0,this.root.classList.remove("hidden"),this.render()}get isVisible(){return this.visible}update(t){this.last={...this.last,...t},this.visible&&this.render()}row(t,e,s=""){return`
${t}${e}
`}render(){const t=this.last,e=t.rtt>150?"crit":t.rtt>60?"bad":"",s=t.fps===0?"bad":"",n=t.bitrate===0?"bad":"";this.root.innerHTML=this.row("fps",t.fps?`${t.fps}`:"—",s)+this.row("rtt",t.rtt?`${t.rtt} ms`:"—",e)+this.row("bitrate",t.bitrate?R(t.bitrate):"—",n)+this.row("res",t.width?`${t.width}×${t.height}`:"—")+this.row("link",t.online?"up":"down",t.online?"":"crit")}}const b=document.getElementById("connect"),k=document.getElementById("viewer"),C=document.getElementById("stage"),K=document.getElementById("stats"),B=document.getElementById("viewer-hint"),p=new N,u=new J(C),f=new V(K),$=new U(i=>p.send(i));let h=null;p.onMessage(i=>j(i));p.setVideoHandler(i=>u.feed(i));p.onStats(i=>f.update({bitrate:i.bitrate,rtt:i.rtt,online:p.connected}));u.onFps=i=>f.update({fps:i});u.onFirstFrame=()=>f.show();function j(i){switch(i.type){case"displays":h&&(h.resolve(i.displays),h=null);break;case"started":Y(i);break;case"error":h?(h.reject(new Error(i.message)),h=null):d(`Agent: ${i.message}`,"err");break;case"stopped":case"stream-ended":X(i.type);break;case"fingerprint-refresh":d("Agent certificate rotated — reconnect to refresh fingerprint","info");break}}function Y(i){u.configure(i.codec,i.width,i.height),b.classList.add("hidden"),k.classList.remove("hidden"),f.show(),$.attach(C),_(),d(`Streaming ${i.width}×${i.height}`,"ok")}function X(i){d(i==="stopped"?"Stream stopped":"Stream ended","info"),$.detach(),u.reset(),k.classList.add("hidden"),b.classList.remove("hidden"),x.render()}function _(){window.setTimeout(()=>{B&&(B.style.opacity="0")},6e3)}window.addEventListener("keydown",i=>{if(i.key==="~"||i.key==="`"){if(k.classList.contains("hidden"))return;f.toggle()}});const z={connectAndList:i=>new Promise((t,e)=>{const s=q(i.host,i.port??52020);p.connect({url:s,fingerprintHex:i.fingerprint}).then(()=>{h={resolve:t,reject:e},p.listDisplays(),window.setTimeout(()=>{h&&(h.reject(new Error("timed out waiting for displays")),h=null)},8e3)}).catch(e)}),startStream:i=>{p.start(i)},onConnected:i=>x.saveRecent(i)},x=new Q(b,z);x.render(); diff --git a/src/web/dist/assets/index-Cq7M16kb.js b/src/web/dist/assets/index-Cq7M16kb.js new file mode 100644 index 0000000..05a5bdf --- /dev/null +++ b/src/web/dist/assets/index-Cq7M16kb.js @@ -0,0 +1,2 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))s(n);new MutationObserver(n=>{for(const r of n)if(r.type==="childList")for(const c of r.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&s(c)}).observe(document,{childList:!0,subtree:!0});function e(n){const r={};return n.integrity&&(r.integrity=n.integrity),n.referrerPolicy&&(r.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?r.credentials="include":n.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function s(n){if(n.ep)return;n.ep=!0;const r=e(n);fetch(n.href,r)}})();function o(i,t={},e=[]){const s=document.createElement(i);for(const[n,r]of Object.entries(t))r!==void 0&&(n==="class"?s.className=r:n==="text"?s.textContent=r:s.setAttribute(n,r));for(const n of e)s.append(n);return s}function H(i){const t=i.replace(/[^0-9a-fA-F]/g,"");if(t.length%2!==0)throw new Error("invalid hex length");const e=new Uint8Array(t.length/2);for(let s=0;ss.classList.add("hidden"),e))}class O{wt=null;ctrlWriter=null;ctrlReader=null;msgHandler=null;videoHandler=null;statsHandler=null;ctrlBuf=new Uint8Array(0);enc=new TextEncoder;dec=new TextDecoder;rxBytes=0;windowBytes=0;lastWindow=performance.now();bitrate=0;pendingSince=0;rtt=0;statsTimer;closed=!1;onMessage(t){this.msgHandler=t}setVideoHandler(t){this.videoHandler=t}onStats(t){this.statsHandler=t}get connected(){return this.wt!==null}async connect(t){this.closed=!1;const e=H(t.fingerprintHex);if(e.length!==32)throw new Error("fingerprint must be a 32-byte SHA-256 hex string");const s=new WebTransport(t.url,{serverCertificateHashes:[{algorithm:"sha-256",value:e}]});this.wt=s,s.closed.then(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})},()=>{this.closed||this.msgHandler?.({type:"stream-ended"})}),await s.ready;const n=await s.createBidirectionalStream();this.ctrlWriter=n.writable.getWriter(),this.ctrlReader=n.readable.getReader(),this.readControlLoop(),this.readVideoLoop(),this.statsTimer=window.setInterval(()=>this.tickStats(),1e3)}send(t){if(!this.ctrlWriter)throw new Error("not connected");(t.type==="list-displays"||t.type==="start")&&(this.pendingSince=performance.now());const e=this.enc.encode(JSON.stringify(t)+` +`);this.ctrlWriter.write(e).catch(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})})}listDisplays(){this.send({type:"list-displays"})}start(t,e={}){this.send({type:"start",display_id:t,...e})}stop(){this.send({type:"stop"})}getStats(){return{bitrate:this.bitrate,rtt:this.rtt,rxBytes:this.rxBytes}}close(){this.closed=!0,this.statsTimer&&window.clearInterval(this.statsTimer),this.statsTimer=void 0;try{this.ctrlWriter?.close()}catch{}try{this.wt?.close()}catch{}this.wt=null,this.ctrlWriter=null,this.ctrlReader=null}async readControlLoop(){const t=this.ctrlReader;if(t)try{for(;;){const{value:e,done:s}=await t.read();if(s)break;if(!e)continue;this.ctrlBuf=R(this.ctrlBuf,e);let n;for(;(n=J(this.ctrlBuf))!==-1;){const r=this.dec.decode(this.ctrlBuf.subarray(0,n));this.ctrlBuf=this.ctrlBuf.subarray(n+1);const c=r.trim();if(!c)continue;let d;try{d=JSON.parse(c)}catch{continue}(d.type==="displays"||d.type==="started")&&this.pendingSince&&(this.rtt=Math.round(performance.now()-this.pendingSince),this.pendingSince=0),this.msgHandler?.(d)}}}catch{}}async readVideoLoop(){const t=this.wt;if(t)try{const e=t.incomingUnidirectionalStreams.getReader();for(;;){const{value:s,done:n}=await e.read();if(n)break;if(!s)continue;const r=s.readable.getReader();for(;;){const{value:c,done:d}=await r.read();if(d)break;c&&(this.rxBytes+=c.byteLength,this.windowBytes+=c.byteLength,this.videoHandler?.(c))}}}catch{}}tickStats(){const t=performance.now(),e=(t-this.lastWindow)/1e3;e>0&&(this.bitrate=Math.round(this.windowBytes*8/e),this.windowBytes=0,this.lastWindow=t),this.statsHandler?.(this.getStats())}}function R(i,t){const e=new Uint8Array(i.length+t.length);return e.set(i,0),e.set(t,i.length),e}function J(i){for(let t=0;t"u")throw new Error("WebCodecs VideoDecoder is not available in this browser")}configure(t,e,s){if(this.videoDecoder&&this.videoDecoder.state!=="closed")try{this.videoDecoder.reset()}catch{}this.videoDecoder=new VideoDecoder({output:n=>this.onFrame(n),error:n=>console.error("[decoder] error",n)}),this.videoDecoder.configure(q(t,e,s))}onFrame(t){const e=t.displayWidth,s=t.displayHeight;(this.canvas.width!==e||this.canvas.height!==s)&&(this.canvas.width=e,this.canvas.height=s),this.ctx.drawImage(t,0,0,e,s),t.close(),this.frameCount++,this.firstFrameDrawn||(this.firstFrameDrawn=!0,this.onFirstFrame?.())}feed(t){if(!this.videoDecoder||this.videoDecoder.state!=="configured")return;this.buf=P(this.buf,t);let e=0,s=0;const n=this.buf;for(;s+2=1&&(this.fps=Math.round(this.frameCount/e),this.frameCount=0,this.fpsWindowStart=t,this.onFps?.(this.fps))}get currentFps(){return this.fps}reset(){this.buf=new Uint8Array(0),this.dts=0,this.sps=null,this.pps=null;try{this.videoDecoder?.reset()}catch{}}close(){try{this.videoDecoder?.close()}catch{}this.videoDecoder=null}}function P(i,t){const e=new Uint8Array(i.length+t.length);return e.set(i,0),e.set(t,i.length),e}function U(i){const t=new Uint8Array([0,0,0,1]);let e=0;for(const r of i)e+=4+r.length;const s=new Uint8Array(e);let n=0;for(const r of i)s.set(t,n),n+=4,s.set(r,n),n+=r.length;return s}class Q{send;target=null;locked=!1;bound=[];constructor(t){this.send=t}attach(t){this.target=t,this.on(t,"click",()=>{!this.locked&&t.requestPointerLock&&t.requestPointerLock()}),this.on(document,"pointerlockchange",()=>{this.locked=document.pointerLockElement===t,t.classList.toggle("locked",this.locked),this.locked&&this.tryKeyboardLock()}),this.on(t,"mousemove",e=>{if(!this.locked)return;const s=e;this.send({type:"input",kind:"mouse",dx:s.movementX,dy:s.movementY,buttons:s.buttons})}),this.on(t,"mousedown",e=>{const s=e;this.send({type:"input",kind:"mousedown",button:s.button})}),this.on(t,"mouseup",e=>{const s=e;this.send({type:"input",kind:"mouseup",button:s.button})}),this.on(t,"wheel",e=>{if(!this.locked)return;e.preventDefault();const s=e;this.send({type:"input",kind:"wheel",dx:s.deltaX,dy:s.deltaY})},{passive:!1}),this.on(t,"contextmenu",e=>e.preventDefault()),this.on(window,"keydown",e=>this.onKey(e,!0)),this.on(window,"keyup",e=>this.onKey(e,!1)),this.on(t,"touchstart",e=>this.onTouch(e,"start"),{passive:!1}),this.on(t,"touchmove",e=>this.onTouch(e,"move"),{passive:!1}),this.on(t,"touchend",e=>this.onTouch(e,"end"),{passive:!1})}get isLocked(){return this.locked}release(){this.locked&&document.exitPointerLock&&document.exitPointerLock();const t=navigator.keyboard;t?.unlock&&t.unlock()}detach(){this.release();for(const[t,e,s,n]of this.bound)t.removeEventListener(e,s,n);this.bound=[],this.target=null}on(t,e,s,n){t.addEventListener(e,s,n),this.bound.push([t,e,s,n??!1])}tryKeyboardLock(){const t=navigator.keyboard;t?.lock&&t.lock(["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"]).catch(()=>{})}onKey(t,e){if(!this.locked)return;["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","'","/","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"].includes(t.key)&&t.preventDefault(),this.send({type:"input",kind:"key",code:t.code,down:e})}onTouch(t,e){if(!this.target)return;t.preventDefault();const s=this.target.getBoundingClientRect(),n=t.changedTouches;for(let r=0;rthis.doConnect());const S=o("button",{class:"btn ghost",text:"Scan QR"});S.addEventListener("click",()=>this.openQr()),w.append(this.connectBtn,S);const F=o("div",{class:"field"},[o("label",{text:"…or paste a connection JSON"})]),v=o("textarea",{placeholder:'{"host":"10.10.1.5","port":52020,"fingerprint":"abcd…","label":"pc"}',spellcheck:"false"});v.addEventListener("change",()=>this.applyJson(v.value)),F.append(v),t.append(s,n,d,w,F);const I=this.loadRecent();if(I.length){const L=o("div",{class:"recent"},[o("h2",{text:"Recent hosts"})]),E=o("div",{class:"recent-list"});for(const l of I){const g=o("div",{class:"recent-item"}),T=o("div",{class:"meta"},[o("div",{class:"host",text:l.label?`${l.label} · ${l.host}:${l.port}`:`${l.host}:${l.port}`}),o("div",{class:"fp",text:l.fingerprint})]);g.append(T);const D=o("button",{class:"del",title:"forget",text:"×"});D.addEventListener("click",$=>{$.stopPropagation(),this.forget(l.fingerprint+l.host),this.render()}),g.append(D),g.addEventListener("click",()=>{this.fpInput.value=l.fingerprint,this.hostInput.value=l.host,this.portInput.value=String(l.port),l.label&&(this.labelInput.value=l.label)}),E.append(g)}L.append(E),t.append(L)}this.root.append(t),this.fpInput.value=e.fingerprint??""}suggestFromPage(){const t={host:location.hostname||"",port:52020,fingerprint:void 0};return fetch("/api/info").then(e=>e.json()).then(e=>{e?.fingerprint&&!this.fpInput.value&&(this.fpInput.value=e.fingerprint),Array.isArray(e?.ips)&&e.ips.length&&!this.hostInput.value&&(this.hostInput.value=e.ips[0])}).catch(()=>{}),t}async doConnect(){const t=this.fpInput.value.trim().replace(/\s+/g,""),e=this.hostInput.value.trim(),s=parseInt(this.portInput.value.trim(),10)||52020,n=this.labelInput.value.trim()||void 0;if(!/^[0-9a-fA-F]{64}$/.test(t)){a("Fingerprint must be 64 hex characters","err");return}if(!e){a("Enter the agent host","err");return}this.connectBtn.disabled=!0,this.connectBtn.textContent="Connecting…";try{const r=await this.deps.connectAndList({host:e,port:s,fingerprint:t,label:n});this.deps.onConnected?.({host:e,port:s,fingerprint:t,label:n}),this.showDisplays(r,{host:e,port:s,fingerprint:t,label:n})}catch(r){a(`Connection failed: ${r.message}`,"err"),this.connectBtn.disabled=!1,this.connectBtn.textContent="Connect"}}showDisplays(t,e){this.root.innerHTML="";const s=o("div",{class:"connect-card"});s.append(o("div",{class:"brand"},[o("span",{class:"dot"}),o("h1",{text:"Choose a display"})]),o("div",{class:"subtitle",text:`${e.host}:${e.port}`})),t.length||s.append(o("div",{class:"muted",text:"No displays reported by the agent yet."}));const n=o("div",{class:"displays"});for(const r of t){const c=o("button",{class:"display-tile"},[o("div",{class:"name",text:`Display ${r.id}`}),o("div",{class:"res",text:`${r.width}×${r.height} @ ${r.refresh_rate||"?"}Hz`})]);c.addEventListener("click",()=>{this.deps.startStream(r.id)}),n.append(c)}s.append(n),this.root.append(s)}applyJson(t){let e;try{e=JSON.parse(t.trim())}catch{return a("Invalid connection JSON","err"),!1}return e===null||Array.isArray(e)||typeof e!="object"?(a("Invalid connection JSON: expected object","err"),!1):e.fingerprint&&typeof e.fingerprint!="string"?(a("Invalid connection JSON: fingerprint must be string","err"),!1):e.host&&typeof e.host!="string"?(a("Invalid connection JSON: host must be string","err"),!1):e.port!==void 0&&typeof e.port!="number"?(a("Invalid connection JSON: port must be number","err"),!1):e.label!==void 0&&typeof e.label!="string"?(a("Invalid connection JSON: label must be string","err"),!1):(e.fingerprint&&(this.fpInput.value=e.fingerprint),e.host&&(this.hostInput.value=e.host),e.port&&(this.portInput.value=String(e.port)),e.label&&(this.labelInput.value=e.label),a("Filled from JSON","ok"),!0)}openQr(){if(!window.BarcodeDetector){a("QR scanning requires Chrome/Edge with BarcodeDetector","err");return}const t=o("div",{class:"qr-modal"}),e=o("div",{class:"box"},[o("h3",{text:"Scan connection QR"})]),s=o("video",{class:"qr-video",playsinline:"true"});e.append(s),e.append(o("div",{class:"muted",text:"Point your camera at the agent’s QR code."}));const n=o("button",{class:"btn ghost",text:"Cancel"});n.addEventListener("click",()=>this.stopQr(t)),e.append(o("div",{class:"actions"},[n])),t.append(e),document.body.append(t),navigator.mediaDevices.getUserMedia({video:{facingMode:"environment"}}).then(async r=>{if(!t.isConnected){r.getTracks().forEach(c=>c.stop());return}this.qrStream=r,s.srcObject=r,await s.play(),this.scanLoop(s,t)}).catch(()=>{a("Camera unavailable","err"),this.stopQr(t)})}async scanLoop(t,e){if(!this.qrStream||!window.BarcodeDetector)return;const s=new BarcodeDetector({formats:["qr"]}),n=async()=>{if(this.qrStream){try{const r=await s.detect(t);for(const c of r)if(this.applyJson(c.rawValue)){this.stopQr(e);return}}catch{}requestAnimationFrame(n)}};n()}stopQr(t){this.qrStream?.getTracks().forEach(e=>e.stop()),this.qrStream=null,t.remove()}loadRecent(){try{const t=localStorage.getItem(y);return t?JSON.parse(t).sort((s,n)=>n.last-s.last).slice(0,8):[]}catch{return[]}}remember(t){try{const e=this.loadRecent().filter(s=>!(s.host===t.host&&s.port===(t.port??52020)));e.unshift({host:t.host,port:t.port??52020,fingerprint:t.fingerprint,label:t.label,last:Date.now()}),localStorage.setItem(y,JSON.stringify(e.slice(0,8)))}catch{}}forget(t){try{const e=this.loadRecent().filter(s=>s.fingerprint+s.host!==t);localStorage.setItem(y,JSON.stringify(e))}catch{}}saveRecent(t){this.remember(t)}}class K{root;visible=!1;last={fps:0,rtt:0,bitrate:0,width:0,height:0,online:!1};constructor(t){this.root=t}toggle(){this.visible=!this.visible,this.root.classList.toggle("hidden",!this.visible),this.visible&&this.render()}show(){this.visible=!0,this.root.classList.remove("hidden"),this.render()}get isVisible(){return this.visible}update(t){this.last={...this.last,...t},this.visible&&this.render()}row(t,e,s=""){return`
${t}${e}
`}render(){const t=this.last,e=t.rtt>150?"crit":t.rtt>60?"bad":"",s=t.fps===0?"bad":"",n=t.bitrate===0?"bad":"";this.root.innerHTML=this.row("fps",t.fps?`${t.fps}`:"—",s)+this.row("rtt",t.rtt?`${t.rtt} ms`:"—",e)+this.row("bitrate",t.bitrate?N(t.bitrate):"—",n)+this.row("res",t.width?`${t.width}×${t.height}`:"—")+this.row("link",t.online?"up":"down",t.online?"":"crit")}}const b=document.getElementById("connect"),k=document.getElementById("viewer"),C=document.getElementById("stage"),j=document.getElementById("stats"),B=document.getElementById("viewer-hint"),p=new O,f=new M(C),u=new K(j),A=new Q(i=>p.send(i));let h=null;p.onMessage(i=>Y(i));p.setVideoHandler(i=>f.feed(i));p.onStats(i=>u.update({bitrate:i.bitrate,rtt:i.rtt,online:p.connected}));f.onFps=i=>u.update({fps:i});f.onFirstFrame=()=>u.show();function Y(i){switch(i.type){case"displays":h&&(h.resolve(i.displays),h=null);break;case"started":X(i);break;case"error":h?(h.reject(new Error(i.message)),h=null):a(`Agent: ${i.message}`,"err");break;case"stopped":case"stream-ended":_(i.type);break;case"fingerprint-refresh":a("Agent certificate rotated — reconnect to refresh fingerprint","info");break}}function X(i){f.configure(i.codec,i.width,i.height),u.update({width:i.width,height:i.height}),b.classList.add("hidden"),k.classList.remove("hidden"),u.show(),A.attach(C),z(),a(`Streaming ${i.width}×${i.height}`,"ok")}function _(i){p.close(),a(i==="stopped"?"Stream stopped":"Stream ended","info"),A.detach(),f.reset(),k.classList.add("hidden"),b.classList.remove("hidden"),x.render()}function z(){window.setTimeout(()=>{B&&(B.style.opacity="0")},6e3)}window.addEventListener("keydown",i=>{if(i.key==="~"||i.key==="`"){if(k.classList.contains("hidden"))return;u.toggle()}});const G={connectAndList:i=>new Promise((t,e)=>{const s=W(i.host,i.port??52020);p.connect({url:s,fingerprintHex:i.fingerprint}).then(()=>{h={resolve:t,reject:e},p.listDisplays(),window.setTimeout(()=>{h&&(h.reject(new Error("timed out waiting for displays")),h=null)},8e3)}).catch(e)}),startStream:i=>{p.start(i)},onConnected:i=>x.saveRecent(i)},x=new V(b,G);x.render(); diff --git a/src/web/dist/index.html b/src/web/dist/index.html index 67b0f24..6e6371d 100644 --- a/src/web/dist/index.html +++ b/src/web/dist/index.html @@ -5,8 +5,8 @@ Distance Desktop - - + +
diff --git a/src/web/src/decoder.ts b/src/web/src/decoder.ts index fcae337..e340bc5 100644 --- a/src/web/src/decoder.ts +++ b/src/web/src/decoder.ts @@ -4,10 +4,8 @@ // split the byte stream into NAL units and feed each to the decoder. function mapCodec(codec: string, width?: number, height?: number): any { - let c = 'avc1.42E01E' // Constrained Baseline 3.0 — safe default for H264 - if (codec === 'hevc' || codec === 'h265') c = 'hvc1.1.6.L93.B0' - else if (codec === 'av1') c = 'av01.0.05M.08' - else if (codec === 'vp9') c = 'vp09.00.10.08' + // H264-only: agent encodes exclusively as H.264 Annex B + const c = 'avc1.42E01E' const cfg: any = { codec: c } if (width && height) { cfg.width = width @@ -22,6 +20,8 @@ export class Decoder { private ctx: CanvasRenderingContext2D private buf = new Uint8Array(0) private dts = 0 + private sps: Uint8Array | null = null + private pps: Uint8Array | null = null private frameCount = 0 private fpsWindowStart = performance.now() @@ -111,15 +111,37 @@ export class Decoder { private decodeNal(nal: Uint8Array): void { if (nal.length === 0) return const nalType = nal[0] & 0x1f - const type: 'key' | 'delta' = nalType === 5 ? 'key' : 'delta' - this.dts += 1000 // µs, monotonic in decode order + if (nalType === 7) { + this.sps = nal.slice() + return + } + if (nalType === 8) { + this.pps = nal.slice() + return + } + // Build access unit: prepend cached SPS/PPS to IDR so decoder has params + let data: Uint8Array + let type: 'key' | 'delta' + if (nalType === 5) { + type = 'key' + if (this.sps && this.pps) { + data = annexBConcat([this.sps, this.pps, nal]) + } else { + data = nal.slice() + } + } else if (nalType === 6) { + // SEI — skip + return + } else { + type = 'delta' + data = nal.slice() + } + this.dts += 33333 // ~30fps access unit tick try { this.videoDecoder!.decode( - new EncodedVideoChunk({ type, timestamp: this.dts, data: nal as unknown as BufferSource }) + new EncodedVideoChunk({ type, timestamp: this.dts, data: data as unknown as BufferSource }) ) } catch (e) { - // Decoding a NAL before SPS/PPS is configured can throw; ignore until - // the stream establishes a valid state. if (!(e instanceof DOMException && e.name === 'InvalidStateError')) { console.warn('[decoder] decode threw', e) } @@ -144,6 +166,8 @@ export class Decoder { reset(): void { this.buf = new Uint8Array(0) this.dts = 0 + this.sps = null + this.pps = null try { this.videoDecoder?.reset() } catch { @@ -167,3 +191,18 @@ function concat(a: Uint8Array, b: Uint8Array): Uint8Array { out.set(b, a.length) return out } + +function annexBConcat(nals: Uint8Array[]): Uint8Array { + const sc = new Uint8Array([0, 0, 0, 1]) + let total = 0 + for (const n of nals) total += 4 + n.length + const out = new Uint8Array(total) + let off = 0 + for (const n of nals) { + out.set(sc, off) + off += 4 + out.set(n, off) + off += n.length + } + return out +} diff --git a/src/web/src/main.ts b/src/web/src/main.ts index 09a10c2..bf2bd1d 100644 --- a/src/web/src/main.ts +++ b/src/web/src/main.ts @@ -63,6 +63,7 @@ function onStarted(msg: Extract): void { stats.update({ width: msg.width, height: msg.height }) connectEl.classList.add('hidden') viewerEl.classList.remove('hidden') + // width/height already published to overlay; now reveal it stats.show() input.attach(canvas) fadeHint() @@ -70,6 +71,7 @@ function onStarted(msg: Extract): void { } function onStreamEnded(kind: string): void { + transport.close() toast(kind === 'stopped' ? 'Stream stopped' : 'Stream ended', 'info') input.detach() decoder.reset() diff --git a/src/web/src/transport.ts b/src/web/src/transport.ts index 37989ef..f431927 100644 --- a/src/web/src/transport.ts +++ b/src/web/src/transport.ts @@ -66,6 +66,7 @@ export class Transport { } async connect(opts: ConnectOptions): Promise { + this.closed = false const hash = hexToBytes(opts.fingerprintHex) if (hash.length !== 32) { throw new Error('fingerprint must be a 32-byte SHA-256 hex string') @@ -108,7 +109,9 @@ export class Transport { this.pendingSince = performance.now() } const bytes = this.enc.encode(JSON.stringify(msg) + '\n') - this.ctrlWriter.write(bytes) + this.ctrlWriter.write(bytes).catch(() => { + if (!this.closed) this.msgHandler?.({ type: 'stream-ended' } as ServerMessage) + }) } listDisplays(): void { @@ -130,6 +133,7 @@ export class Transport { close(): void { this.closed = true if (this.statsTimer) window.clearInterval(this.statsTimer) + this.statsTimer = undefined try { this.ctrlWriter?.close() } catch { @@ -140,6 +144,9 @@ export class Transport { } catch { /* ignore */ } + this.wt = null + this.ctrlWriter = null + this.ctrlReader = null } private async readControlLoop(): Promise { diff --git a/src/web/src/ui/connect.ts b/src/web/src/ui/connect.ts index c640b3c..e64609a 100644 --- a/src/web/src/ui/connect.ts +++ b/src/web/src/ui/connect.ts @@ -139,7 +139,7 @@ export class ConnectScreen { fetch('/api/info') .then((r) => r.json()) .then((d) => { - if (d?.fingerprint) { + if (d?.fingerprint && !this.fpInput.value) { this.fpInput.value = d.fingerprint } if (Array.isArray(d?.ips) && d.ips.length && !this.hostInput.value) { diff --git a/src/web/style.css b/src/web/style.css index 78724d6..5d3eca5 100644 --- a/src/web/style.css +++ b/src/web/style.css @@ -11,8 +11,8 @@ --accent-2: #3df0a8; --danger: #ff5c6c; --warn: #ffcf5c; - --mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, monospace; - --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", menlo, monospace; + --sans: system-ui, -apple-system, "Segoe UI", roboto, helvetica, arial, sans-serif; --radius: 12px; } From fc4b9d404709f603477ccae45e1dba73b915b920 Mon Sep 17 00:00:00 2001 From: Hermet Date: Mon, 31 Aug 2026 14:00:25 -0400 Subject: [PATCH 7/8] feat: --allow-origin for viewers hosted off the agent The WT upgrader only accepted an empty Origin, an Origin matching the request Host, or the agent's own :52022 web UI. A viewer served from anywhere else (reverse proxy, separate web deployment) was rejected with a 400 before the session opened. --allow-origin is repeatable and takes "*" to allow any origin. Also documents in AGENTS.md the unsolicited connect-time displays push, the optional codec/bitrate on start, the absent input/ping handlers, the rotation broadcast only reaching live-stream subscribers, and that --cert/--key disables the cert manager and :52022 entirely. --- AGENTS.md | 29 +++++++++++++++++++++++++++++ src/main.go | 9 +++++++++ src/types.go | 11 +++++++++++ 3 files changed, 49 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5cd1c1c..2e56145 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,23 @@ JSON control messages (bidirectional stream): {"type":"fingerprint-refresh","algorithm":"sha-256","fingerprint":""} // sent on connect + cert rotation ``` +`start` also accepts optional `codec` and `bitrate`. + +On connect the agent pushes `fingerprint-refresh` (only when it manages its own +cert) followed by an **unsolicited `displays`**, before the client asks for +anything. Clients must tolerate `displays` arriving unprompted. + +Unrecognized message types are answered with +`{"type":"error","message":"unknown type: "}`. There is currently no `input` +or `ping` handler. + +### Origin checks + +WT upgrades accept an empty `Origin`, an `Origin` matching the request `Host`, +and the agent's own `https://:`. A viewer served from any other +origin (reverse proxy, separate web deployment) is rejected unless that origin is +passed with `--allow-origin`. + ### Cert system - Self-signed ECDSA P-256 certificate, 13-day validity @@ -81,6 +98,7 @@ Embedded HTML at `http://:52022/` showing: | `--sunshine "addr=host:47989"` | Sunshine/Moonlight host address | | `--vnc "addr=host:5901"` | VNC server address | | `--rdp "addr=host:3389"` | RDP server address | +| `--allow-origin ` | Additional allowed browser `Origin` for WT upgrades (repeatable; `*` allows any). Needed when the viewer is hosted somewhere other than the agent's own `:52022`, e.g. behind a reverse proxy. | | `--dry-run` | List displays via the selected backend and exit | ### Architecture @@ -127,6 +145,17 @@ activeBackend (chosen via --backend at startup): 4. Client caches additional fingerprint 5. On next connection, includes both old and new hashes in `serverCertificateHashes` +**Caveat:** the rotation broadcast only reaches sessions subscribed to a *live* +stream — `broadcastControlMsg` returns early when no stream is active, and +subscribers are only registered when a stream exists at connect time. A +connected-but-idle client is not notified. The connect-time push in +`handleSession` is unconditional. + +**With `--cert`/`--key`** there is no cert manager at all: no fingerprint push, +no rotation loop, and no web UI on `:52022`. That is the reverse-proxy / +publicly-trusted-cert mode, where clients connect without +`serverCertificateHashes`. + ## Dependencies - `github.com/okdaichi/webtransport-go` — WebTransport over QUIC/HTTP-3 diff --git a/src/main.go b/src/main.go index eeaaba8..d213e79 100644 --- a/src/main.go +++ b/src/main.go @@ -108,6 +108,8 @@ func main() { flag.StringVar(&bopts.vnc, "vnc", "", `vnc backend opts: "addr=10.10.1.6:5901"`) flag.StringVar(&bopts.rdp, "rdp", "", `rdp backend opts: "addr=10.10.1.6:3389"`) dryRun := flag.Bool("dry-run", false, "list displays via the selected backend and exit") + var allowOrigins stringList + flag.Var(&allowOrigins, "allow-origin", "additional allowed browser Origin for WebTransport upgrades (repeatable, e.g. https://distance.example.com); \"*\" allows any") flag.Parse() configureBackends(bopts) @@ -173,6 +175,13 @@ func main() { return true } } + // Operator-supplied origins, for viewers hosted elsewhere (reverse + // proxy, CDN, separate web deployment). + for _, allowed := range allowOrigins { + if allowed == "*" || allowed == origin { + return true + } + } log.Printf("WT upgrade rejected origin %q from %s", origin, r.RemoteAddr) return false }, diff --git a/src/types.go b/src/types.go index 10716e3..93a438a 100644 --- a/src/types.go +++ b/src/types.go @@ -2,6 +2,7 @@ package main import ( "context" + "strings" "sync" "github.com/okdaichi/webtransport-go" @@ -9,6 +10,16 @@ import ( "distancedesktop/agent/src/backend" ) +// stringList collects a repeatable string flag (e.g. --allow-origin). +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ",") } + +func (s *stringList) Set(v string) error { + *s = append(*s, v) + return nil +} + // subscriber wraps one WebTransport session's control + video streams. type subscriber struct { sess *webtransport.Session From a4dc248b022eeb37b9810d5af4501992f9480712 Mon Sep 17 00:00:00 2001 From: Hermet Date: Wed, 2 Sep 2026 15:55:26 -0400 Subject: [PATCH 8/8] fix: embedded viewer never decoded video; port client-side fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded viewer on :52022 had the same defects as the standalone client, found by instrumenting VideoDecoder in a real browser against a live agent. Ported the fixes verbatim from distancedesktop/client. transport.ts: incomingUnidirectionalStreams yields a WebTransportReceiveStream, which *is* a ReadableStream and has no `.readable` property. `recv.readable.getReader()` therefore threw a TypeError that readVideoLoop's outer catch swallowed as 'stream closed', so the control plane stayed healthy, the UI reported streaming, and zero bytes ever reached the decoder — with no console error. Verified from a browser: constructor WebTransportReceiveStream, instanceof ReadableStream true, 'readable' in stream false, and reading it directly yields Annex B starting 00 00 00 01 67. Each stream is now drained in its own task so a second video stream after stop/start is picked up promptly. decoder.ts: four defects, any one of which alone prevents decoding. - mapCodec hardcoded avc1.42E01E (Baseline 3.0) and ignored its argument. Real streams are Main 3.2 (avc1.4d4020) from the KMS path and High 4.0 (avc1.640028) from the PipeWire path. Derived from the SPS now. - Only IDR NALs got start codes; P-frames were passed bare, which is invalid in Annex B mode. Now builds an avcC description from SPS/PPS and converts each access unit to AVCC with 4-byte length prefixes. - One EncodedVideoChunk per NAL, so a multi-NAL access unit became several bogus frames. NALs are now grouped into access units at first_mb_in_slice == 0. The previous heuristic (a VCL NAL after a non-VCL NAL) silently depended on the encoder emitting SEIs between frames: ffmpeg's Main output does, its High output does not, and there 300 NALs collapsed into 3 access units. - Access units before the first keyframe were fed to the decoder. A viewer always joins mid-GOP because the encoder is already running, so those reference an untransmitted PPS; the resulting fatal error moves VideoDecoder to 'closed' and the IDR behind them never decodes. They are dropped now, and a fatal decoder error tears the decoder down so the next parameter sets reconfigure cleanly. - Timestamps derived from a frame index at the negotiated rate instead of a fixed 33333us tick, which described 30fps for a 60fps stream. main.ts now passes the requested fps to both `start` and the decoder so they cannot disagree. input.ts: guard mousedown/mouseup on `locked`, as mousemove, wheel and onKey already were. Without it the click that acquires pointer lock also injects a button press and release on the remote host. Rebuilt the committed dist. tsc --noEmit, npm run build, go vet ./... and go build ./... all clean. --- src/web/dist/assets/index-Cq7M16kb.js | 2 - src/web/dist/assets/index-zx29XWV1.js | 2 + src/web/dist/index.html | 2 +- src/web/src/decoder.ts | 447 ++++++++++++++++++-------- src/web/src/input.ts | 5 + src/web/src/main.ts | 8 +- src/web/src/transport.ts | 38 ++- 7 files changed, 355 insertions(+), 149 deletions(-) delete mode 100644 src/web/dist/assets/index-Cq7M16kb.js create mode 100644 src/web/dist/assets/index-zx29XWV1.js diff --git a/src/web/dist/assets/index-Cq7M16kb.js b/src/web/dist/assets/index-Cq7M16kb.js deleted file mode 100644 index 05a5bdf..0000000 --- a/src/web/dist/assets/index-Cq7M16kb.js +++ /dev/null @@ -1,2 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const n of document.querySelectorAll('link[rel="modulepreload"]'))s(n);new MutationObserver(n=>{for(const r of n)if(r.type==="childList")for(const c of r.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&s(c)}).observe(document,{childList:!0,subtree:!0});function e(n){const r={};return n.integrity&&(r.integrity=n.integrity),n.referrerPolicy&&(r.referrerPolicy=n.referrerPolicy),n.crossOrigin==="use-credentials"?r.credentials="include":n.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function s(n){if(n.ep)return;n.ep=!0;const r=e(n);fetch(n.href,r)}})();function o(i,t={},e=[]){const s=document.createElement(i);for(const[n,r]of Object.entries(t))r!==void 0&&(n==="class"?s.className=r:n==="text"?s.textContent=r:s.setAttribute(n,r));for(const n of e)s.append(n);return s}function H(i){const t=i.replace(/[^0-9a-fA-F]/g,"");if(t.length%2!==0)throw new Error("invalid hex length");const e=new Uint8Array(t.length/2);for(let s=0;ss.classList.add("hidden"),e))}class O{wt=null;ctrlWriter=null;ctrlReader=null;msgHandler=null;videoHandler=null;statsHandler=null;ctrlBuf=new Uint8Array(0);enc=new TextEncoder;dec=new TextDecoder;rxBytes=0;windowBytes=0;lastWindow=performance.now();bitrate=0;pendingSince=0;rtt=0;statsTimer;closed=!1;onMessage(t){this.msgHandler=t}setVideoHandler(t){this.videoHandler=t}onStats(t){this.statsHandler=t}get connected(){return this.wt!==null}async connect(t){this.closed=!1;const e=H(t.fingerprintHex);if(e.length!==32)throw new Error("fingerprint must be a 32-byte SHA-256 hex string");const s=new WebTransport(t.url,{serverCertificateHashes:[{algorithm:"sha-256",value:e}]});this.wt=s,s.closed.then(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})},()=>{this.closed||this.msgHandler?.({type:"stream-ended"})}),await s.ready;const n=await s.createBidirectionalStream();this.ctrlWriter=n.writable.getWriter(),this.ctrlReader=n.readable.getReader(),this.readControlLoop(),this.readVideoLoop(),this.statsTimer=window.setInterval(()=>this.tickStats(),1e3)}send(t){if(!this.ctrlWriter)throw new Error("not connected");(t.type==="list-displays"||t.type==="start")&&(this.pendingSince=performance.now());const e=this.enc.encode(JSON.stringify(t)+` -`);this.ctrlWriter.write(e).catch(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})})}listDisplays(){this.send({type:"list-displays"})}start(t,e={}){this.send({type:"start",display_id:t,...e})}stop(){this.send({type:"stop"})}getStats(){return{bitrate:this.bitrate,rtt:this.rtt,rxBytes:this.rxBytes}}close(){this.closed=!0,this.statsTimer&&window.clearInterval(this.statsTimer),this.statsTimer=void 0;try{this.ctrlWriter?.close()}catch{}try{this.wt?.close()}catch{}this.wt=null,this.ctrlWriter=null,this.ctrlReader=null}async readControlLoop(){const t=this.ctrlReader;if(t)try{for(;;){const{value:e,done:s}=await t.read();if(s)break;if(!e)continue;this.ctrlBuf=R(this.ctrlBuf,e);let n;for(;(n=J(this.ctrlBuf))!==-1;){const r=this.dec.decode(this.ctrlBuf.subarray(0,n));this.ctrlBuf=this.ctrlBuf.subarray(n+1);const c=r.trim();if(!c)continue;let d;try{d=JSON.parse(c)}catch{continue}(d.type==="displays"||d.type==="started")&&this.pendingSince&&(this.rtt=Math.round(performance.now()-this.pendingSince),this.pendingSince=0),this.msgHandler?.(d)}}}catch{}}async readVideoLoop(){const t=this.wt;if(t)try{const e=t.incomingUnidirectionalStreams.getReader();for(;;){const{value:s,done:n}=await e.read();if(n)break;if(!s)continue;const r=s.readable.getReader();for(;;){const{value:c,done:d}=await r.read();if(d)break;c&&(this.rxBytes+=c.byteLength,this.windowBytes+=c.byteLength,this.videoHandler?.(c))}}}catch{}}tickStats(){const t=performance.now(),e=(t-this.lastWindow)/1e3;e>0&&(this.bitrate=Math.round(this.windowBytes*8/e),this.windowBytes=0,this.lastWindow=t),this.statsHandler?.(this.getStats())}}function R(i,t){const e=new Uint8Array(i.length+t.length);return e.set(i,0),e.set(t,i.length),e}function J(i){for(let t=0;t"u")throw new Error("WebCodecs VideoDecoder is not available in this browser")}configure(t,e,s){if(this.videoDecoder&&this.videoDecoder.state!=="closed")try{this.videoDecoder.reset()}catch{}this.videoDecoder=new VideoDecoder({output:n=>this.onFrame(n),error:n=>console.error("[decoder] error",n)}),this.videoDecoder.configure(q(t,e,s))}onFrame(t){const e=t.displayWidth,s=t.displayHeight;(this.canvas.width!==e||this.canvas.height!==s)&&(this.canvas.width=e,this.canvas.height=s),this.ctx.drawImage(t,0,0,e,s),t.close(),this.frameCount++,this.firstFrameDrawn||(this.firstFrameDrawn=!0,this.onFirstFrame?.())}feed(t){if(!this.videoDecoder||this.videoDecoder.state!=="configured")return;this.buf=P(this.buf,t);let e=0,s=0;const n=this.buf;for(;s+2=1&&(this.fps=Math.round(this.frameCount/e),this.frameCount=0,this.fpsWindowStart=t,this.onFps?.(this.fps))}get currentFps(){return this.fps}reset(){this.buf=new Uint8Array(0),this.dts=0,this.sps=null,this.pps=null;try{this.videoDecoder?.reset()}catch{}}close(){try{this.videoDecoder?.close()}catch{}this.videoDecoder=null}}function P(i,t){const e=new Uint8Array(i.length+t.length);return e.set(i,0),e.set(t,i.length),e}function U(i){const t=new Uint8Array([0,0,0,1]);let e=0;for(const r of i)e+=4+r.length;const s=new Uint8Array(e);let n=0;for(const r of i)s.set(t,n),n+=4,s.set(r,n),n+=r.length;return s}class Q{send;target=null;locked=!1;bound=[];constructor(t){this.send=t}attach(t){this.target=t,this.on(t,"click",()=>{!this.locked&&t.requestPointerLock&&t.requestPointerLock()}),this.on(document,"pointerlockchange",()=>{this.locked=document.pointerLockElement===t,t.classList.toggle("locked",this.locked),this.locked&&this.tryKeyboardLock()}),this.on(t,"mousemove",e=>{if(!this.locked)return;const s=e;this.send({type:"input",kind:"mouse",dx:s.movementX,dy:s.movementY,buttons:s.buttons})}),this.on(t,"mousedown",e=>{const s=e;this.send({type:"input",kind:"mousedown",button:s.button})}),this.on(t,"mouseup",e=>{const s=e;this.send({type:"input",kind:"mouseup",button:s.button})}),this.on(t,"wheel",e=>{if(!this.locked)return;e.preventDefault();const s=e;this.send({type:"input",kind:"wheel",dx:s.deltaX,dy:s.deltaY})},{passive:!1}),this.on(t,"contextmenu",e=>e.preventDefault()),this.on(window,"keydown",e=>this.onKey(e,!0)),this.on(window,"keyup",e=>this.onKey(e,!1)),this.on(t,"touchstart",e=>this.onTouch(e,"start"),{passive:!1}),this.on(t,"touchmove",e=>this.onTouch(e,"move"),{passive:!1}),this.on(t,"touchend",e=>this.onTouch(e,"end"),{passive:!1})}get isLocked(){return this.locked}release(){this.locked&&document.exitPointerLock&&document.exitPointerLock();const t=navigator.keyboard;t?.unlock&&t.unlock()}detach(){this.release();for(const[t,e,s,n]of this.bound)t.removeEventListener(e,s,n);this.bound=[],this.target=null}on(t,e,s,n){t.addEventListener(e,s,n),this.bound.push([t,e,s,n??!1])}tryKeyboardLock(){const t=navigator.keyboard;t?.lock&&t.lock(["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"]).catch(()=>{})}onKey(t,e){if(!this.locked)return;["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","'","/","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"].includes(t.key)&&t.preventDefault(),this.send({type:"input",kind:"key",code:t.code,down:e})}onTouch(t,e){if(!this.target)return;t.preventDefault();const s=this.target.getBoundingClientRect(),n=t.changedTouches;for(let r=0;rthis.doConnect());const S=o("button",{class:"btn ghost",text:"Scan QR"});S.addEventListener("click",()=>this.openQr()),w.append(this.connectBtn,S);const F=o("div",{class:"field"},[o("label",{text:"…or paste a connection JSON"})]),v=o("textarea",{placeholder:'{"host":"10.10.1.5","port":52020,"fingerprint":"abcd…","label":"pc"}',spellcheck:"false"});v.addEventListener("change",()=>this.applyJson(v.value)),F.append(v),t.append(s,n,d,w,F);const I=this.loadRecent();if(I.length){const L=o("div",{class:"recent"},[o("h2",{text:"Recent hosts"})]),E=o("div",{class:"recent-list"});for(const l of I){const g=o("div",{class:"recent-item"}),T=o("div",{class:"meta"},[o("div",{class:"host",text:l.label?`${l.label} · ${l.host}:${l.port}`:`${l.host}:${l.port}`}),o("div",{class:"fp",text:l.fingerprint})]);g.append(T);const D=o("button",{class:"del",title:"forget",text:"×"});D.addEventListener("click",$=>{$.stopPropagation(),this.forget(l.fingerprint+l.host),this.render()}),g.append(D),g.addEventListener("click",()=>{this.fpInput.value=l.fingerprint,this.hostInput.value=l.host,this.portInput.value=String(l.port),l.label&&(this.labelInput.value=l.label)}),E.append(g)}L.append(E),t.append(L)}this.root.append(t),this.fpInput.value=e.fingerprint??""}suggestFromPage(){const t={host:location.hostname||"",port:52020,fingerprint:void 0};return fetch("/api/info").then(e=>e.json()).then(e=>{e?.fingerprint&&!this.fpInput.value&&(this.fpInput.value=e.fingerprint),Array.isArray(e?.ips)&&e.ips.length&&!this.hostInput.value&&(this.hostInput.value=e.ips[0])}).catch(()=>{}),t}async doConnect(){const t=this.fpInput.value.trim().replace(/\s+/g,""),e=this.hostInput.value.trim(),s=parseInt(this.portInput.value.trim(),10)||52020,n=this.labelInput.value.trim()||void 0;if(!/^[0-9a-fA-F]{64}$/.test(t)){a("Fingerprint must be 64 hex characters","err");return}if(!e){a("Enter the agent host","err");return}this.connectBtn.disabled=!0,this.connectBtn.textContent="Connecting…";try{const r=await this.deps.connectAndList({host:e,port:s,fingerprint:t,label:n});this.deps.onConnected?.({host:e,port:s,fingerprint:t,label:n}),this.showDisplays(r,{host:e,port:s,fingerprint:t,label:n})}catch(r){a(`Connection failed: ${r.message}`,"err"),this.connectBtn.disabled=!1,this.connectBtn.textContent="Connect"}}showDisplays(t,e){this.root.innerHTML="";const s=o("div",{class:"connect-card"});s.append(o("div",{class:"brand"},[o("span",{class:"dot"}),o("h1",{text:"Choose a display"})]),o("div",{class:"subtitle",text:`${e.host}:${e.port}`})),t.length||s.append(o("div",{class:"muted",text:"No displays reported by the agent yet."}));const n=o("div",{class:"displays"});for(const r of t){const c=o("button",{class:"display-tile"},[o("div",{class:"name",text:`Display ${r.id}`}),o("div",{class:"res",text:`${r.width}×${r.height} @ ${r.refresh_rate||"?"}Hz`})]);c.addEventListener("click",()=>{this.deps.startStream(r.id)}),n.append(c)}s.append(n),this.root.append(s)}applyJson(t){let e;try{e=JSON.parse(t.trim())}catch{return a("Invalid connection JSON","err"),!1}return e===null||Array.isArray(e)||typeof e!="object"?(a("Invalid connection JSON: expected object","err"),!1):e.fingerprint&&typeof e.fingerprint!="string"?(a("Invalid connection JSON: fingerprint must be string","err"),!1):e.host&&typeof e.host!="string"?(a("Invalid connection JSON: host must be string","err"),!1):e.port!==void 0&&typeof e.port!="number"?(a("Invalid connection JSON: port must be number","err"),!1):e.label!==void 0&&typeof e.label!="string"?(a("Invalid connection JSON: label must be string","err"),!1):(e.fingerprint&&(this.fpInput.value=e.fingerprint),e.host&&(this.hostInput.value=e.host),e.port&&(this.portInput.value=String(e.port)),e.label&&(this.labelInput.value=e.label),a("Filled from JSON","ok"),!0)}openQr(){if(!window.BarcodeDetector){a("QR scanning requires Chrome/Edge with BarcodeDetector","err");return}const t=o("div",{class:"qr-modal"}),e=o("div",{class:"box"},[o("h3",{text:"Scan connection QR"})]),s=o("video",{class:"qr-video",playsinline:"true"});e.append(s),e.append(o("div",{class:"muted",text:"Point your camera at the agent’s QR code."}));const n=o("button",{class:"btn ghost",text:"Cancel"});n.addEventListener("click",()=>this.stopQr(t)),e.append(o("div",{class:"actions"},[n])),t.append(e),document.body.append(t),navigator.mediaDevices.getUserMedia({video:{facingMode:"environment"}}).then(async r=>{if(!t.isConnected){r.getTracks().forEach(c=>c.stop());return}this.qrStream=r,s.srcObject=r,await s.play(),this.scanLoop(s,t)}).catch(()=>{a("Camera unavailable","err"),this.stopQr(t)})}async scanLoop(t,e){if(!this.qrStream||!window.BarcodeDetector)return;const s=new BarcodeDetector({formats:["qr"]}),n=async()=>{if(this.qrStream){try{const r=await s.detect(t);for(const c of r)if(this.applyJson(c.rawValue)){this.stopQr(e);return}}catch{}requestAnimationFrame(n)}};n()}stopQr(t){this.qrStream?.getTracks().forEach(e=>e.stop()),this.qrStream=null,t.remove()}loadRecent(){try{const t=localStorage.getItem(y);return t?JSON.parse(t).sort((s,n)=>n.last-s.last).slice(0,8):[]}catch{return[]}}remember(t){try{const e=this.loadRecent().filter(s=>!(s.host===t.host&&s.port===(t.port??52020)));e.unshift({host:t.host,port:t.port??52020,fingerprint:t.fingerprint,label:t.label,last:Date.now()}),localStorage.setItem(y,JSON.stringify(e.slice(0,8)))}catch{}}forget(t){try{const e=this.loadRecent().filter(s=>s.fingerprint+s.host!==t);localStorage.setItem(y,JSON.stringify(e))}catch{}}saveRecent(t){this.remember(t)}}class K{root;visible=!1;last={fps:0,rtt:0,bitrate:0,width:0,height:0,online:!1};constructor(t){this.root=t}toggle(){this.visible=!this.visible,this.root.classList.toggle("hidden",!this.visible),this.visible&&this.render()}show(){this.visible=!0,this.root.classList.remove("hidden"),this.render()}get isVisible(){return this.visible}update(t){this.last={...this.last,...t},this.visible&&this.render()}row(t,e,s=""){return`
${t}${e}
`}render(){const t=this.last,e=t.rtt>150?"crit":t.rtt>60?"bad":"",s=t.fps===0?"bad":"",n=t.bitrate===0?"bad":"";this.root.innerHTML=this.row("fps",t.fps?`${t.fps}`:"—",s)+this.row("rtt",t.rtt?`${t.rtt} ms`:"—",e)+this.row("bitrate",t.bitrate?N(t.bitrate):"—",n)+this.row("res",t.width?`${t.width}×${t.height}`:"—")+this.row("link",t.online?"up":"down",t.online?"":"crit")}}const b=document.getElementById("connect"),k=document.getElementById("viewer"),C=document.getElementById("stage"),j=document.getElementById("stats"),B=document.getElementById("viewer-hint"),p=new O,f=new M(C),u=new K(j),A=new Q(i=>p.send(i));let h=null;p.onMessage(i=>Y(i));p.setVideoHandler(i=>f.feed(i));p.onStats(i=>u.update({bitrate:i.bitrate,rtt:i.rtt,online:p.connected}));f.onFps=i=>u.update({fps:i});f.onFirstFrame=()=>u.show();function Y(i){switch(i.type){case"displays":h&&(h.resolve(i.displays),h=null);break;case"started":X(i);break;case"error":h?(h.reject(new Error(i.message)),h=null):a(`Agent: ${i.message}`,"err");break;case"stopped":case"stream-ended":_(i.type);break;case"fingerprint-refresh":a("Agent certificate rotated — reconnect to refresh fingerprint","info");break}}function X(i){f.configure(i.codec,i.width,i.height),u.update({width:i.width,height:i.height}),b.classList.add("hidden"),k.classList.remove("hidden"),u.show(),A.attach(C),z(),a(`Streaming ${i.width}×${i.height}`,"ok")}function _(i){p.close(),a(i==="stopped"?"Stream stopped":"Stream ended","info"),A.detach(),f.reset(),k.classList.add("hidden"),b.classList.remove("hidden"),x.render()}function z(){window.setTimeout(()=>{B&&(B.style.opacity="0")},6e3)}window.addEventListener("keydown",i=>{if(i.key==="~"||i.key==="`"){if(k.classList.contains("hidden"))return;u.toggle()}});const G={connectAndList:i=>new Promise((t,e)=>{const s=W(i.host,i.port??52020);p.connect({url:s,fingerprintHex:i.fingerprint}).then(()=>{h={resolve:t,reject:e},p.listDisplays(),window.setTimeout(()=>{h&&(h.reject(new Error("timed out waiting for displays")),h=null)},8e3)}).catch(e)}),startStream:i=>{p.start(i)},onConnected:i=>x.saveRecent(i)},x=new V(b,G);x.render(); diff --git a/src/web/dist/assets/index-zx29XWV1.js b/src/web/dist/assets/index-zx29XWV1.js new file mode 100644 index 0000000..1a35387 --- /dev/null +++ b/src/web/dist/assets/index-zx29XWV1.js @@ -0,0 +1,2 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const r of i)if(r.type==="childList")for(const c of r.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&n(c)}).observe(document,{childList:!0,subtree:!0});function e(i){const r={};return i.integrity&&(r.integrity=i.integrity),i.referrerPolicy&&(r.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?r.credentials="include":i.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function n(i){if(i.ep)return;i.ep=!0;const r=e(i);fetch(i.href,r)}})();function o(s,t={},e=[]){const n=document.createElement(s);for(const[i,r]of Object.entries(t))r!==void 0&&(i==="class"?n.className=r:i==="text"?n.textContent=r:n.setAttribute(i,r));for(const i of e)n.append(i);return n}function W(s){const t=s.replace(/[^0-9a-fA-F]/g,"");if(t.length%2!==0)throw new Error("invalid hex length");const e=new Uint8Array(t.length/2);for(let n=0;nn.classList.add("hidden"),e))}class K{wt=null;ctrlWriter=null;ctrlReader=null;msgHandler=null;videoHandler=null;statsHandler=null;ctrlBuf=new Uint8Array(0);enc=new TextEncoder;dec=new TextDecoder;rxBytes=0;windowBytes=0;lastWindow=performance.now();bitrate=0;pendingSince=0;rtt=0;statsTimer;closed=!1;onMessage(t){this.msgHandler=t}setVideoHandler(t){this.videoHandler=t}onStats(t){this.statsHandler=t}get connected(){return this.wt!==null}async connect(t){this.closed=!1;const e=W(t.fingerprintHex);if(e.length!==32)throw new Error("fingerprint must be a 32-byte SHA-256 hex string");const n=new WebTransport(t.url,{serverCertificateHashes:[{algorithm:"sha-256",value:e}]});this.wt=n,n.closed.then(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})},()=>{this.closed||this.msgHandler?.({type:"stream-ended"})}),await n.ready;const i=await n.createBidirectionalStream();this.ctrlWriter=i.writable.getWriter(),this.ctrlReader=i.readable.getReader(),this.readControlLoop(),this.readVideoLoop(),this.statsTimer=window.setInterval(()=>this.tickStats(),1e3)}send(t){if(!this.ctrlWriter)throw new Error("not connected");(t.type==="list-displays"||t.type==="start")&&(this.pendingSince=performance.now());const e=this.enc.encode(JSON.stringify(t)+` +`);this.ctrlWriter.write(e).catch(()=>{this.closed||this.msgHandler?.({type:"stream-ended"})})}listDisplays(){this.send({type:"list-displays"})}start(t,e={}){this.send({type:"start",display_id:t,...e})}stop(){this.send({type:"stop"})}getStats(){return{bitrate:this.bitrate,rtt:this.rtt,rxBytes:this.rxBytes}}close(){this.closed=!0,this.statsTimer&&window.clearInterval(this.statsTimer),this.statsTimer=void 0;try{this.ctrlWriter?.close()}catch{}try{this.wt?.close()}catch{}this.wt=null,this.ctrlWriter=null,this.ctrlReader=null}async readControlLoop(){const t=this.ctrlReader;if(t)try{for(;;){const{value:e,done:n}=await t.read();if(n)break;if(!e)continue;this.ctrlBuf=M(this.ctrlBuf,e);let i;for(;(i=q(this.ctrlBuf))!==-1;){const r=this.dec.decode(this.ctrlBuf.subarray(0,i));this.ctrlBuf=this.ctrlBuf.subarray(i+1);const c=r.trim();if(!c)continue;let h;try{h=JSON.parse(c)}catch{continue}(h.type==="displays"||h.type==="started")&&this.pendingSince&&(this.rtt=Math.round(performance.now()-this.pendingSince),this.pendingSince=0),this.msgHandler?.(h)}}}catch{}}async readVideoLoop(){const t=this.wt;if(t)try{const e=t.incomingUnidirectionalStreams.getReader();for(;;){const{value:n,done:i}=await e.read();if(i)break;n&&this.pumpVideoStream(n)}}catch{}}async pumpVideoStream(t){const e=t.getReader();try{for(;;){const{value:n,done:i}=await e.read();if(i)break;!n||n.byteLength===0||(this.rxBytes+=n.byteLength,this.windowBytes+=n.byteLength,this.videoHandler?.(n))}}catch(n){this.closed||console.warn("[transport] video stream error",n)}finally{try{e.releaseLock()}catch{}}}tickStats(){const t=performance.now(),e=(t-this.lastWindow)/1e3;e>0&&(this.bitrate=Math.round(this.windowBytes*8/e),this.windowBytes=0,this.lastWindow=t),this.statsHandler?.(this.getStats())}}function M(s,t){const e=new Uint8Array(s.length+t.length);return e.set(s,0),e.set(t,s.length),e}function q(s){for(let t=0;t=1&&s<=5}function _(s){return s.length>1&&(s[1]&128)!==0}function j(s,t){const e=new Uint8Array(8+s.length+1+2+t.length);let n=0;return e[n++]=1,e[n++]=s[1],e[n++]=s[2],e[n++]=s[3],e[n++]=255,e[n++]=225,e[n++]=s.length>>8&255,e[n++]=s.length&255,e.set(s,n),n+=s.length,e[n++]=1,e[n++]=t.length>>8&255,e[n++]=t.length&255,e.set(t,n),n+=t.length,e.subarray(0,n)}function Y(s){const t=e=>e.toString(16).padStart(2,"0");return`avc1.${t(s[1])}${t(s[2])}${t(s[3])}`}function z(s,t){const e=new Uint8Array(s.length+t.length);return e.set(s,0),e.set(t,s.length),e}function T(s,t){for(let e=t;e+3<=s.length;e++)if(s[e]===0&&s[e+1]===0&&(s[e+2]===1||s[e+2]===0&&e+3"u")throw new Error("WebCodecs VideoDecoder is not available in this browser")}configure(t,e,n,i){this.reset(),i&&i>0&&(this.fps=i)}feed(t){for(this.buf=z(this.buf,t);;){const e=this.nextNal();if(!e)break;this.ingestNal(e)}}nextNal(){const t=T(this.buf,0);if(t===-1){const r=Math.max(0,this.buf.length-3);return this.buf=this.buf.subarray(r),null}const e=t+X(this.buf,t),n=T(this.buf,e);if(n===-1)return this.buf=this.buf.subarray(t),null;const i=this.buf.subarray(e,n);return this.buf=this.buf.subarray(n),i}ingestNal(t){if(t.length===0)return;const e=y(t);if(e===C?this.sps=t.slice():e===E&&(this.pps=t.slice()),$(e)&&_(t)&&this.pendingAU.length>0){let n=this.pendingAU.length;for(;n>0&&!$(y(this.pendingAU[n-1]));)n--;n>0&&(this.emitAU(this.pendingAU.slice(0,n)),this.pendingAU=this.pendingAU.slice(n))}this.pendingAU.push(t)}emitAU(t){t.some(a=>{const m=y(a);return m===C||m===E})&&this.sps&&this.pps&&this.configureDecoder(this.sps,this.pps);const n=t.some(a=>y(a)===Q);if(!this.seenKeyframe){if(!n){this.droppedBeforeKeyframe++;return}this.seenKeyframe=!0,this.droppedBeforeKeyframe>0&&console.info(`[decoder] dropped ${this.droppedBeforeKeyframe} access unit(s) before the first keyframe`)}if(!this.configured){this.pendingBeforeConfig.length<8&&this.pendingBeforeConfig.push(t);return}if(!this.videoDecoder||this.videoDecoder.state!=="configured")return;let i=0;for(const a of t)i+=4+a.length;const r=new Uint8Array(i);let c=0;for(const a of t)r[c++]=a.length>>24&255,r[c++]=a.length>>16&255,r[c++]=a.length>>8&255,r[c++]=a.length&255,r.set(a,c),c+=a.length;const h=Math.round(this.frameIndex*1e6/this.fps),f=Math.round(1e6/this.fps);try{this.videoDecoder.decode(new EncodedVideoChunk({type:n?"key":"delta",timestamp:h,duration:f,data:r})),this.frameIndex++}catch(a){a instanceof DOMException&&a.name==="InvalidStateError"||console.warn("[decoder] decode threw",a)}}onDecoderError(t){console.error("[decoder] error",t),this.configured=!1,this.seenKeyframe=!1,this.sps=null,this.pps=null,this.pendingBeforeConfig=[],this.videoDecoder=null}configureDecoder(t,e){const n=Y(t);if(!(this.configured&&this.codec===n)){if(this.videoDecoder&&this.videoDecoder.state!=="closed")try{this.videoDecoder.reset()}catch{}this.videoDecoder=new VideoDecoder({output:i=>this.onFrame(i),error:i=>this.onDecoderError(i)});try{this.videoDecoder.configure({codec:n,description:j(t,e),optimizeForLatency:!0})}catch(i){console.error("[decoder] configure failed",n,i);return}if(this.codec=n,this.configured=!0,console.info("[decoder] configured",n),this.pendingBeforeConfig.length){const i=this.pendingBeforeConfig;this.pendingBeforeConfig=[];for(const r of i)this.emitAU(r)}}}onFrame(t){const e=t.displayWidth||t.codedWidth,n=t.displayHeight||t.codedHeight;(this.canvas.width!==e||this.canvas.height!==n)&&(this.canvas.width=e,this.canvas.height=n),this.ctx.drawImage(t,0,0,e,n),t.close(),this.frameCount++,this.tickFps(),this.firstFrameDrawn||(this.firstFrameDrawn=!0,this.onFirstFrame?.())}tickFps(){const t=performance.now(),e=(t-this.fpsWindowStart)/1e3;e>=1&&(this.measuredFps=Math.round(this.frameCount/e),this.frameCount=0,this.fpsWindowStart=t,this.onFps?.(this.measuredFps))}get currentFps(){return this.measuredFps}reset(){this.buf=new Uint8Array(0),this.pendingAU=[],this.pendingBeforeConfig=[],this.sps=null,this.pps=null,this.configured=!1,this.codec="",this.frameIndex=0,this.frameCount=0,this.measuredFps=0,this.firstFrameDrawn=!1,this.seenKeyframe=!1,this.droppedBeforeKeyframe=0;try{this.videoDecoder?.reset()}catch{}this.videoDecoder=null}close(){try{this.videoDecoder?.close()}catch{}this.videoDecoder=null,this.configured=!1}}class Z{send;target=null;locked=!1;bound=[];constructor(t){this.send=t}attach(t){this.target=t,this.on(t,"click",()=>{!this.locked&&t.requestPointerLock&&t.requestPointerLock()}),this.on(document,"pointerlockchange",()=>{this.locked=document.pointerLockElement===t,t.classList.toggle("locked",this.locked),this.locked&&this.tryKeyboardLock()}),this.on(t,"mousemove",e=>{if(!this.locked)return;const n=e;this.send({type:"input",kind:"mouse",dx:n.movementX,dy:n.movementY,buttons:n.buttons})}),this.on(t,"mousedown",e=>{if(!this.locked)return;const n=e;this.send({type:"input",kind:"mousedown",button:n.button})}),this.on(t,"mouseup",e=>{if(!this.locked)return;const n=e;this.send({type:"input",kind:"mouseup",button:n.button})}),this.on(t,"wheel",e=>{if(!this.locked)return;e.preventDefault();const n=e;this.send({type:"input",kind:"wheel",dx:n.deltaX,dy:n.deltaY})},{passive:!1}),this.on(t,"contextmenu",e=>e.preventDefault()),this.on(window,"keydown",e=>this.onKey(e,!0)),this.on(window,"keyup",e=>this.onKey(e,!1)),this.on(t,"touchstart",e=>this.onTouch(e,"start"),{passive:!1}),this.on(t,"touchmove",e=>this.onTouch(e,"move"),{passive:!1}),this.on(t,"touchend",e=>this.onTouch(e,"end"),{passive:!1})}get isLocked(){return this.locked}release(){this.locked&&document.exitPointerLock&&document.exitPointerLock();const t=navigator.keyboard;t?.unlock&&t.unlock()}detach(){this.release();for(const[t,e,n,i]of this.bound)t.removeEventListener(e,n,i);this.bound=[],this.target=null}on(t,e,n,i){t.addEventListener(e,n,i),this.bound.push([t,e,n,i??!1])}tryKeyboardLock(){const t=navigator.keyboard;t?.lock&&t.lock(["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"]).catch(()=>{})}onKey(t,e){if(!this.locked)return;["Tab","Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","'","/","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"].includes(t.key)&&t.preventDefault(),this.send({type:"input",kind:"key",code:t.code,down:e})}onTouch(t,e){if(!this.target)return;t.preventDefault();const n=this.target.getBoundingClientRect(),i=t.changedTouches;for(let r=0;rthis.doConnect());const a=o("button",{class:"btn ghost",text:"Scan QR"});a.addEventListener("click",()=>this.openQr()),f.append(this.connectBtn,a);const m=o("div",{class:"field"},[o("label",{text:"…or paste a connection JSON"})]),b=o("textarea",{placeholder:'{"host":"10.10.1.5","port":52020,"fingerprint":"abcd…","label":"pc"}',spellcheck:"false"});b.addEventListener("change",()=>this.applyJson(b.value)),m.append(b),t.append(n,i,h,f,m);const I=this.loadRecent();if(I.length){const B=o("div",{class:"recent"},[o("h2",{text:"Recent hosts"})]),A=o("div",{class:"recent-list"});for(const l of I){const v=o("div",{class:"recent-item"}),O=o("div",{class:"meta"},[o("div",{class:"host",text:l.label?`${l.label} · ${l.host}:${l.port}`:`${l.host}:${l.port}`}),o("div",{class:"fp",text:l.fingerprint})]);v.append(O);const D=o("button",{class:"del",title:"forget",text:"×"});D.addEventListener("click",P=>{P.stopPropagation(),this.forget(l.fingerprint+l.host),this.render()}),v.append(D),v.addEventListener("click",()=>{this.fpInput.value=l.fingerprint,this.hostInput.value=l.host,this.portInput.value=String(l.port),l.label&&(this.labelInput.value=l.label)}),A.append(v)}B.append(A),t.append(B)}this.root.append(t),this.fpInput.value=e.fingerprint??""}suggestFromPage(){const t={host:location.hostname||"",port:52020,fingerprint:void 0};return fetch("/api/info").then(e=>e.json()).then(e=>{e?.fingerprint&&!this.fpInput.value&&(this.fpInput.value=e.fingerprint),Array.isArray(e?.ips)&&e.ips.length&&!this.hostInput.value&&(this.hostInput.value=e.ips[0])}).catch(()=>{}),t}async doConnect(){const t=this.fpInput.value.trim().replace(/\s+/g,""),e=this.hostInput.value.trim(),n=parseInt(this.portInput.value.trim(),10)||52020,i=this.labelInput.value.trim()||void 0;if(!/^[0-9a-fA-F]{64}$/.test(t)){d("Fingerprint must be 64 hex characters","err");return}if(!e){d("Enter the agent host","err");return}this.connectBtn.disabled=!0,this.connectBtn.textContent="Connecting…";try{const r=await this.deps.connectAndList({host:e,port:n,fingerprint:t,label:i});this.deps.onConnected?.({host:e,port:n,fingerprint:t,label:i}),this.showDisplays(r,{host:e,port:n,fingerprint:t,label:i})}catch(r){d(`Connection failed: ${r.message}`,"err"),this.connectBtn.disabled=!1,this.connectBtn.textContent="Connect"}}showDisplays(t,e){this.root.innerHTML="";const n=o("div",{class:"connect-card"});n.append(o("div",{class:"brand"},[o("span",{class:"dot"}),o("h1",{text:"Choose a display"})]),o("div",{class:"subtitle",text:`${e.host}:${e.port}`})),t.length||n.append(o("div",{class:"muted",text:"No displays reported by the agent yet."}));const i=o("div",{class:"displays"});for(const r of t){const c=o("button",{class:"display-tile"},[o("div",{class:"name",text:`Display ${r.id}`}),o("div",{class:"res",text:`${r.width}×${r.height} @ ${r.refresh_rate||"?"}Hz`})]);c.addEventListener("click",()=>{this.deps.startStream(r.id)}),i.append(c)}n.append(i),this.root.append(n)}applyJson(t){let e;try{e=JSON.parse(t.trim())}catch{return d("Invalid connection JSON","err"),!1}return e===null||Array.isArray(e)||typeof e!="object"?(d("Invalid connection JSON: expected object","err"),!1):e.fingerprint&&typeof e.fingerprint!="string"?(d("Invalid connection JSON: fingerprint must be string","err"),!1):e.host&&typeof e.host!="string"?(d("Invalid connection JSON: host must be string","err"),!1):e.port!==void 0&&typeof e.port!="number"?(d("Invalid connection JSON: port must be number","err"),!1):e.label!==void 0&&typeof e.label!="string"?(d("Invalid connection JSON: label must be string","err"),!1):(e.fingerprint&&(this.fpInput.value=e.fingerprint),e.host&&(this.hostInput.value=e.host),e.port&&(this.portInput.value=String(e.port)),e.label&&(this.labelInput.value=e.label),d("Filled from JSON","ok"),!0)}openQr(){if(!window.BarcodeDetector){d("QR scanning requires Chrome/Edge with BarcodeDetector","err");return}const t=o("div",{class:"qr-modal"}),e=o("div",{class:"box"},[o("h3",{text:"Scan connection QR"})]),n=o("video",{class:"qr-video",playsinline:"true"});e.append(n),e.append(o("div",{class:"muted",text:"Point your camera at the agent’s QR code."}));const i=o("button",{class:"btn ghost",text:"Cancel"});i.addEventListener("click",()=>this.stopQr(t)),e.append(o("div",{class:"actions"},[i])),t.append(e),document.body.append(t),navigator.mediaDevices.getUserMedia({video:{facingMode:"environment"}}).then(async r=>{if(!t.isConnected){r.getTracks().forEach(c=>c.stop());return}this.qrStream=r,n.srcObject=r,await n.play(),this.scanLoop(n,t)}).catch(()=>{d("Camera unavailable","err"),this.stopQr(t)})}async scanLoop(t,e){if(!this.qrStream||!window.BarcodeDetector)return;const n=new BarcodeDetector({formats:["qr"]}),i=async()=>{if(this.qrStream){try{const r=await n.detect(t);for(const c of r)if(this.applyJson(c.rawValue)){this.stopQr(e);return}}catch{}requestAnimationFrame(i)}};i()}stopQr(t){this.qrStream?.getTracks().forEach(e=>e.stop()),this.qrStream=null,t.remove()}loadRecent(){try{const t=localStorage.getItem(S);return t?JSON.parse(t).sort((n,i)=>i.last-n.last).slice(0,8):[]}catch{return[]}}remember(t){try{const e=this.loadRecent().filter(n=>!(n.host===t.host&&n.port===(t.port??52020)));e.unshift({host:t.host,port:t.port??52020,fingerprint:t.fingerprint,label:t.label,last:Date.now()}),localStorage.setItem(S,JSON.stringify(e.slice(0,8)))}catch{}}forget(t){try{const e=this.loadRecent().filter(n=>n.fingerprint+n.host!==t);localStorage.setItem(S,JSON.stringify(e))}catch{}}saveRecent(t){this.remember(t)}}class et{root;visible=!1;last={fps:0,rtt:0,bitrate:0,width:0,height:0,online:!1};constructor(t){this.root=t}toggle(){this.visible=!this.visible,this.root.classList.toggle("hidden",!this.visible),this.visible&&this.render()}show(){this.visible=!0,this.root.classList.remove("hidden"),this.render()}get isVisible(){return this.visible}update(t){this.last={...this.last,...t},this.visible&&this.render()}row(t,e,n=""){return`
${t}${e}
`}render(){const t=this.last,e=t.rtt>150?"crit":t.rtt>60?"bad":"",n=t.fps===0?"bad":"",i=t.bitrate===0?"bad":"";this.root.innerHTML=this.row("fps",t.fps?`${t.fps}`:"—",n)+this.row("rtt",t.rtt?`${t.rtt} ms`:"—",e)+this.row("bitrate",t.bitrate?J(t.bitrate):"—",i)+this.row("res",t.width?`${t.width}×${t.height}`:"—")+this.row("link",t.online?"up":"down",t.online?"":"crit")}}const H=60,x=document.getElementById("connect"),F=document.getElementById("viewer"),U=document.getElementById("stage"),nt=document.getElementById("stats"),N=document.getElementById("viewer-hint"),u=new K,w=new G(U),g=new et(nt),R=new Z(s=>u.send(s));let p=null;u.onMessage(s=>st(s));u.setVideoHandler(s=>w.feed(s));u.onStats(s=>g.update({bitrate:s.bitrate,rtt:s.rtt,online:u.connected}));w.onFps=s=>g.update({fps:s});w.onFirstFrame=()=>g.show();function st(s){switch(s.type){case"displays":p&&(p.resolve(s.displays),p=null);break;case"started":it(s);break;case"error":p?(p.reject(new Error(s.message)),p=null):d(`Agent: ${s.message}`,"err");break;case"stopped":case"stream-ended":rt(s.type);break;case"fingerprint-refresh":d("Agent certificate rotated — reconnect to refresh fingerprint","info");break}}function it(s){w.configure(s.codec,s.width,s.height,H),g.update({width:s.width,height:s.height}),x.classList.add("hidden"),F.classList.remove("hidden"),g.show(),R.attach(U),ot(),d(`Streaming ${s.width}×${s.height}`,"ok")}function rt(s){u.close(),d(s==="stopped"?"Stream stopped":"Stream ended","info"),R.detach(),w.reset(),F.classList.add("hidden"),x.classList.remove("hidden"),L.render()}function ot(){window.setTimeout(()=>{N&&(N.style.opacity="0")},6e3)}window.addEventListener("keydown",s=>{if(s.key==="~"||s.key==="`"){if(F.classList.contains("hidden"))return;g.toggle()}});const ct={connectAndList:s=>new Promise((t,e)=>{const n=V(s.host,s.port??52020);u.connect({url:n,fingerprintHex:s.fingerprint}).then(()=>{p={resolve:t,reject:e},u.listDisplays(),window.setTimeout(()=>{p&&(p.reject(new Error("timed out waiting for displays")),p=null)},8e3)}).catch(e)}),startStream:s=>{u.start(s,{fps:H})},onConnected:s=>L.saveRecent(s)},L=new tt(x,ct);L.render(); diff --git a/src/web/dist/index.html b/src/web/dist/index.html index 6e6371d..b95b609 100644 --- a/src/web/dist/index.html +++ b/src/web/dist/index.html @@ -5,7 +5,7 @@ Distance Desktop - + diff --git a/src/web/src/decoder.ts b/src/web/src/decoder.ts index e340bc5..a6d11f1 100644 --- a/src/web/src/decoder.ts +++ b/src/web/src/decoder.ts @@ -1,31 +1,132 @@ -// H264 (Annex B) decoder using the platform WebCodecs VideoDecoder, drawing -// straight onto a . The agent sends raw ffmpeg H264 bytes (Annex B with -// 00 00 00 01 / 00 00 01 start codes) on the unidirectional video stream, so we -// split the byte stream into NAL units and feed each to the decoder. - -function mapCodec(codec: string, width?: number, height?: number): any { - // H264-only: agent encodes exclusively as H.264 Annex B - const c = 'avc1.42E01E' - const cfg: any = { codec: c } - if (width && height) { - cfg.width = width - cfg.height = height +/** + * H264 (Annex B) decoder on the platform WebCodecs `VideoDecoder`, drawing onto + * a . + * + * The agent streams raw ffmpeg H.264 Annex B bytes over a WebTransport + * unidirectional stream — one contiguous byte stream with no framing. So: + * 1. buffer incoming bytes and split them on Annex B start codes + * (00 00 00 01 / 00 00 01); + * 2. group NALs into access units (one per frame), starting a new unit at each + * VCL NAL whose slice header reports first_mb_in_slice == 0, so the + * SPS/PPS/SEI preceding a frame stay attached to it; + * 3. build the AVCDecoderConfigurationRecord (avcC `description`) from the + * SPS/PPS and derive the RFC 6381 `avc1.PPCCLL` codec string from the SPS — + * the profile and level must come from the bitstream, not a constant, or + * `VideoDecoder` silently decodes nothing; + * 4. convert each access unit from Annex B to AVCC (4-byte length prefixes) + * and feed it as one `EncodedVideoChunk`. + * + * Streams are always joined mid-GOP, since the agent's encoder is already + * running when a viewer connects, so access units before the first keyframe are + * discarded (see `emitAU`). + */ + +const NAL_IDR = 5 +const NAL_SPS = 7 +const NAL_PPS = 8 + +function nalType(nal: Uint8Array): number { + return nal[0] & 0x1f +} + +function isVCL(t: number): boolean { + return t >= 1 && t <= 5 +} + +/** + * True when a VCL NAL starts a new picture, i.e. its slice header's + * `first_mb_in_slice` is 0. + * + * That field is the first ue(v) Exp-Golomb value after the 1-byte NAL header, + * and ue(v) == 0 is encoded as a single set bit, so a high bit in the first + * payload byte means "this slice covers macroblock 0" — a new picture. With + * multiple slices per picture only the first has first_mb_in_slice == 0, so this + * still identifies exactly one boundary per frame. + * + * This is the reliable boundary test. Keying off "a VCL NAL following a non-VCL + * NAL" only works when the encoder emits SEI/parameter sets between frames: + * ffmpeg's Main-profile output does, but its High-profile output does not, and + * there consecutive slices would otherwise collapse into a single access unit. + */ +function startsNewPicture(nal: Uint8Array): boolean { + return nal.length > 1 && (nal[1] & 0x80) !== 0 +} + +/** + * Build the AVCDecoderConfigurationRecord (avcC) from SPS/PPS NALs. + * Both are passed without start codes but with their 1-byte NAL header. + */ +function buildAvcC(sps: Uint8Array, pps: Uint8Array): Uint8Array { + const out = new Uint8Array(5 + 1 + 2 + sps.length + 1 + 2 + pps.length) + let o = 0 + out[o++] = 1 // configurationVersion + out[o++] = sps[1] // AVCProfileIndication = profile_idc + out[o++] = sps[2] // profile_compatibility = constraint flags + out[o++] = sps[3] // AVCLevelIndication = level_idc + out[o++] = 0xff // 6 bits reserved + lengthSizeMinusOne = 3 (4-byte lengths) + out[o++] = 0xe1 // 3 bits reserved + numOfSequenceParameterSets = 1 + out[o++] = (sps.length >> 8) & 0xff + out[o++] = sps.length & 0xff + out.set(sps, o) + o += sps.length + out[o++] = 1 // numOfPictureParameterSets + out[o++] = (pps.length >> 8) & 0xff + out[o++] = pps.length & 0xff + out.set(pps, o) + o += pps.length + return out.subarray(0, o) +} + +/** Derive the RFC 6381 codec string (e.g. `avc1.4d4020`) from an SPS NAL. */ +function codecStringFromSps(sps: Uint8Array): string { + const h = (n: number) => n.toString(16).padStart(2, '0') + return `avc1.${h(sps[1])}${h(sps[2])}${h(sps[3])}` +} + +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length) + out.set(a, 0) + out.set(b, a.length) + return out +} + +/** Index of the first Annex B start code at or after `from`, else -1. */ +function findStartCode(buf: Uint8Array, from: number): number { + for (let i = from; i + 3 <= buf.length; i++) { + if (buf[i] === 0x00 && buf[i + 1] === 0x00) { + if (buf[i + 2] === 0x01) return i + if (buf[i + 2] === 0x00 && i + 3 < buf.length && buf[i + 3] === 0x01) return i + } } - return cfg + return -1 +} + +/** Length of the start code at index `i` (3 or 4). */ +function startCodeLen(buf: Uint8Array, i: number): number { + return i + 3 < buf.length && buf[i + 3] === 0x01 ? 4 : 3 } export class Decoder { private videoDecoder: VideoDecoder | null = null private canvas: HTMLCanvasElement private ctx: CanvasRenderingContext2D - private buf = new Uint8Array(0) - private dts = 0 + + private buf: Uint8Array = new Uint8Array(0) + private pendingAU: Uint8Array[] = [] + private pendingBeforeConfig: Uint8Array[][] = [] + private sps: Uint8Array | null = null private pps: Uint8Array | null = null + private configured = false + private codec = '' + private fps = 60 + private frameIndex = 0 + private seenKeyframe = false + private droppedBeforeKeyframe = 0 private frameCount = 0 private fpsWindowStart = performance.now() - private fps = 0 + private measuredFps = 0 public onFps: ((fps: number) => void) | null = null public onFirstFrame: (() => void) | null = null @@ -41,106 +142,131 @@ export class Decoder { } } - configure(codec: string, width?: number, height?: number): void { - if (this.videoDecoder && this.videoDecoder.state !== 'closed') { - try { - this.videoDecoder.reset() - } catch { - /* ignore */ - } - } - this.videoDecoder = new VideoDecoder({ - output: (frame) => this.onFrame(frame), - error: (e) => console.error('[decoder] error', e) - }) - this.videoDecoder.configure(mapCodec(codec, width, height)) + /** + * Prepare for a new stream. The agent encodes H.264 only, and the real codec + * string plus dimensions are taken from the SPS once it arrives, so the + * `started` message is used just for the frame-rate hint. + */ + configure(_codec: string, _width?: number, _height?: number, fps?: number): void { + this.reset() + if (fps && fps > 0) this.fps = fps } - private onFrame(frame: VideoFrame): void { - const w = frame.displayWidth - const h = frame.displayHeight - if (this.canvas.width !== w || this.canvas.height !== h) { - this.canvas.width = w - this.canvas.height = h + /** Feed a raw chunk of video bytes; may contain any number of NAL units. */ + feed(chunk: Uint8Array): void { + this.buf = concat(this.buf, chunk) + while (true) { + const nal = this.nextNal() + if (!nal) break + this.ingestNal(nal) } - this.ctx.drawImage(frame as unknown as CanvasImageSource, 0, 0, w, h) - frame.close() + } - this.frameCount++ - if (!this.firstFrameDrawn) { - this.firstFrameDrawn = true - this.onFirstFrame?.() + /** + * Extract the next complete NAL. A NAL's end is only known once the following + * start code appears, so an incomplete trailing NAL stays buffered. + */ + private nextNal(): Uint8Array | null { + const start = findStartCode(this.buf, 0) + if (start === -1) { + // Keep up to 3 trailing bytes that could be the head of a start code. + const keep = Math.max(0, this.buf.length - 3) + this.buf = this.buf.subarray(keep) + return null + } + const dataStart = start + startCodeLen(this.buf, start) + const next = findStartCode(this.buf, dataStart) + if (next === -1) { + this.buf = this.buf.subarray(start) + return null } + const nal = this.buf.subarray(dataStart, next) + this.buf = this.buf.subarray(next) + return nal } - /** Feed a raw chunk of video bytes (may contain many NAL units). */ - feed(chunk: Uint8Array): void { - if (!this.videoDecoder || this.videoDecoder.state !== 'configured') return - this.buf = concat(this.buf, chunk) - let start = 0 - let i = 0 - const buf = this.buf - while (i + 2 < buf.length) { - if (buf[i] === 0 && buf[i + 1] === 0) { - if (i + 3 < buf.length && buf[i + 2] === 0 && buf[i + 3] === 1) { - // 4-byte start code: 00 00 00 01 - if (start < i) { - const nal = buf.subarray(start, i) - this.decodeNal(nal) - } - start = i + 4 - i = start - continue - } else if (buf[i + 2] === 1) { - // 3-byte start code: 00 00 01 - if (start < i) { - const nal = buf.subarray(start, i) - this.decodeNal(nal) - } - start = i + 3 - i = start - continue - } + private ingestNal(nal: Uint8Array): void { + if (nal.length === 0) return + const t = nalType(nal) + if (t === NAL_SPS) this.sps = nal.slice() + else if (t === NAL_PPS) this.pps = nal.slice() + + // A VCL NAL that starts a new picture closes the previous access unit. Any + // trailing non-VCL NALs already buffered (SPS/PPS/SEI) are parameter sets + // for the *new* picture, so they stay with it rather than being emitted. + if (isVCL(t) && startsNewPicture(nal) && this.pendingAU.length > 0) { + let split = this.pendingAU.length + while (split > 0 && !isVCL(nalType(this.pendingAU[split - 1]))) split-- + if (split > 0) { + this.emitAU(this.pendingAU.slice(0, split)) + this.pendingAU = this.pendingAU.slice(split) } - i++ } - this.buf = buf.subarray(start) - this.tickFps() + this.pendingAU.push(nal) } - private decodeNal(nal: Uint8Array): void { - if (nal.length === 0) return - const nalType = nal[0] & 0x1f - if (nalType === 7) { - this.sps = nal.slice() - return - } - if (nalType === 8) { - this.pps = nal.slice() - return + private emitAU(au: Uint8Array[]): void { + const carriesParams = au.some((n) => { + const t = nalType(n) + return t === NAL_SPS || t === NAL_PPS + }) + if (carriesParams && this.sps && this.pps) { + this.configureDecoder(this.sps, this.pps) } - // Build access unit: prepend cached SPS/PPS to IDR so decoder has params - let data: Uint8Array - let type: 'key' | 'delta' - if (nalType === 5) { - type = 'key' - if (this.sps && this.pps) { - data = annexBConcat([this.sps, this.pps, nal]) - } else { - data = nal.slice() + + const isKey = au.some((n) => nalType(n) === NAL_IDR) + + // A stream is joined mid-GOP: the agent's ffmpeg is already running, so the + // first access units received are the tail of the previous GOP and reference + // frames (and a PPS) that were never sent. Feeding those to VideoDecoder + // raises a fatal decode error, which moves it to `closed` and kills the + // IDR that follows. So everything before the first keyframe is dropped. + if (!this.seenKeyframe) { + if (!isKey) { + this.droppedBeforeKeyframe++ + return + } + this.seenKeyframe = true + if (this.droppedBeforeKeyframe > 0) { + console.info( + `[decoder] dropped ${this.droppedBeforeKeyframe} access unit(s) before the first keyframe` + ) } - } else if (nalType === 6) { - // SEI — skip + } + + if (!this.configured) { + // Keyframe arrived but SPS/PPS have not: hold it so the GOP is not lost. + if (this.pendingBeforeConfig.length < 8) this.pendingBeforeConfig.push(au) return - } else { - type = 'delta' - data = nal.slice() } - this.dts += 33333 // ~30fps access unit tick + if (!this.videoDecoder || this.videoDecoder.state !== 'configured') return + + // Annex B -> AVCC: replace start codes with 4-byte lengths. + let size = 0 + for (const n of au) size += 4 + n.length + const avcc = new Uint8Array(size) + let o = 0 + for (const n of au) { + avcc[o++] = (n.length >> 24) & 0xff + avcc[o++] = (n.length >> 16) & 0xff + avcc[o++] = (n.length >> 8) & 0xff + avcc[o++] = n.length & 0xff + avcc.set(n, o) + o += n.length + } + + const timestamp = Math.round((this.frameIndex * 1_000_000) / this.fps) + const duration = Math.round(1_000_000 / this.fps) try { - this.videoDecoder!.decode( - new EncodedVideoChunk({ type, timestamp: this.dts, data: data as unknown as BufferSource }) + this.videoDecoder.decode( + new EncodedVideoChunk({ + type: isKey ? 'key' : 'delta', + timestamp, + duration, + data: avcc as unknown as BufferSource + }) ) + this.frameIndex++ } catch (e) { if (!(e instanceof DOMException && e.name === 'InvalidStateError')) { console.warn('[decoder] decode threw', e) @@ -148,61 +274,112 @@ export class Decoder { } } + /** + * A fatal VideoDecoder error moves it to `closed`, after which every decode + * throws. Recover by tearing the decoder down and waiting for the next + * SPS/PPS + keyframe, rather than leaving a dead decoder in place. + */ + private onDecoderError(e: DOMException): void { + console.error('[decoder] error', e) + this.configured = false + this.seenKeyframe = false + this.sps = null + this.pps = null + this.pendingBeforeConfig = [] + this.videoDecoder = null + } + + private configureDecoder(sps: Uint8Array, pps: Uint8Array): void { + const codec = codecStringFromSps(sps) + if (this.configured && this.codec === codec) return + + if (this.videoDecoder && this.videoDecoder.state !== 'closed') { + try { this.videoDecoder.reset() } catch { /* ignore */ } + } + + this.videoDecoder = new VideoDecoder({ + output: (frame) => this.onFrame(frame), + error: (e) => this.onDecoderError(e) + }) + + try { + this.videoDecoder.configure({ + codec, + // codedWidth/codedHeight come from the SPS inside `description`. + description: buildAvcC(sps, pps) as unknown as BufferSource, + optimizeForLatency: true + }) + } catch (e) { + console.error('[decoder] configure failed', codec, e) + return + } + + this.codec = codec + this.configured = true + console.info('[decoder] configured', codec) + + if (this.pendingBeforeConfig.length) { + const queued = this.pendingBeforeConfig + this.pendingBeforeConfig = [] + for (const au of queued) this.emitAU(au) + } + } + + private onFrame(frame: VideoFrame): void { + const w = frame.displayWidth || frame.codedWidth + const h = frame.displayHeight || frame.codedHeight + if (this.canvas.width !== w || this.canvas.height !== h) { + this.canvas.width = w + this.canvas.height = h + } + this.ctx.drawImage(frame as unknown as CanvasImageSource, 0, 0, w, h) + frame.close() + + this.frameCount++ + this.tickFps() + if (!this.firstFrameDrawn) { + this.firstFrameDrawn = true + this.onFirstFrame?.() + } + } + + /** Rolling fps over decoded frames (not received bytes). */ private tickFps(): void { const now = performance.now() const dt = (now - this.fpsWindowStart) / 1000 if (dt >= 1) { - this.fps = Math.round(this.frameCount / dt) + this.measuredFps = Math.round(this.frameCount / dt) this.frameCount = 0 this.fpsWindowStart = now - this.onFps?.(this.fps) + this.onFps?.(this.measuredFps) } } get currentFps(): number { - return this.fps + return this.measuredFps } reset(): void { this.buf = new Uint8Array(0) - this.dts = 0 + this.pendingAU = [] + this.pendingBeforeConfig = [] this.sps = null this.pps = null - try { - this.videoDecoder?.reset() - } catch { - /* ignore */ - } + this.configured = false + this.codec = '' + this.frameIndex = 0 + this.frameCount = 0 + this.measuredFps = 0 + this.firstFrameDrawn = false + this.seenKeyframe = false + this.droppedBeforeKeyframe = 0 + try { this.videoDecoder?.reset() } catch { /* ignore */ } + this.videoDecoder = null } close(): void { - try { - this.videoDecoder?.close() - } catch { - /* ignore */ - } + try { this.videoDecoder?.close() } catch { /* ignore */ } this.videoDecoder = null + this.configured = false } } - -function concat(a: Uint8Array, b: Uint8Array): Uint8Array { - const out = new Uint8Array(a.length + b.length) - out.set(a, 0) - out.set(b, a.length) - return out -} - -function annexBConcat(nals: Uint8Array[]): Uint8Array { - const sc = new Uint8Array([0, 0, 0, 1]) - let total = 0 - for (const n of nals) total += 4 + n.length - const out = new Uint8Array(total) - let off = 0 - for (const n of nals) { - out.set(sc, off) - off += 4 - out.set(n, off) - off += n.length - } - return out -} diff --git a/src/web/src/input.ts b/src/web/src/input.ts index 47ef705..d6ac788 100644 --- a/src/web/src/input.ts +++ b/src/web/src/input.ts @@ -41,12 +41,17 @@ export class InputController { this.send({ type: 'input', kind: 'mouse', dx: ev.movementX, dy: ev.movementY, buttons: ev.buttons }) }) + // Guarded on `locked` like mousemove/wheel/onKey: without this, the very + // click that acquires pointer lock also injects a button press and release + // on the remote host. this.on(target, 'mousedown', (e) => { + if (!this.locked) return const ev = e as MouseEvent this.send({ type: 'input', kind: 'mousedown', button: ev.button }) }) this.on(target, 'mouseup', (e) => { + if (!this.locked) return const ev = e as MouseEvent this.send({ type: 'input', kind: 'mouseup', button: ev.button }) }) diff --git a/src/web/src/main.ts b/src/web/src/main.ts index bf2bd1d..8664aaa 100644 --- a/src/web/src/main.ts +++ b/src/web/src/main.ts @@ -7,6 +7,10 @@ import { StatsOverlay } from './ui/stats' import { toast } from './util' import type { ConnectPayload, ControlMessage, Display, InputMessage } from './types' +// Frame rate requested from the agent. Also the decoder's timestamp base, so the +// two must agree or presentation timestamps drift from real time. +const REQUESTED_FPS = 60 + const connectEl = document.getElementById('connect')! const viewerEl = document.getElementById('viewer')! const canvas = document.getElementById('stage') as HTMLCanvasElement @@ -59,7 +63,7 @@ function handleMessage(msg: ControlMessage): void { } function onStarted(msg: Extract): void { - decoder.configure(msg.codec, msg.width, msg.height) + decoder.configure(msg.codec, msg.width, msg.height, REQUESTED_FPS) stats.update({ width: msg.width, height: msg.height }) connectEl.classList.add('hidden') viewerEl.classList.remove('hidden') @@ -114,7 +118,7 @@ const deps: ConnectDeps = { .catch(reject) }), startStream: (displayId: number) => { - transport.start(displayId) + transport.start(displayId, { fps: REQUESTED_FPS }) }, onConnected: (payload) => connectScreen.saveRecent(payload) } diff --git a/src/web/src/transport.ts b/src/web/src/transport.ts index f431927..fa8bec9 100644 --- a/src/web/src/transport.ts +++ b/src/web/src/transport.ts @@ -194,21 +194,41 @@ export class Transport { const { value: recv, done: sdone } = await streamReader.read() if (sdone) break if (!recv) continue - const reader = recv.readable.getReader() - while (true) { - const { value, done } = await reader.read() - if (done) break - if (!value) continue - this.rxBytes += value.byteLength - this.windowBytes += value.byteLength - this.videoHandler?.(value) - } + // Each value is a WebTransportReceiveStream, which *is* a ReadableStream. + // It has no `.readable` property: reaching for one throws a TypeError + // that the outer catch would swallow as "stream closed", leaving the + // control plane healthy while no video ever arrives. + void this.pumpVideoStream(recv as unknown as ReadableStream) } } catch { /* stream closed */ } } + // Drained in its own task so a second video stream (after stop/start) is + // picked up promptly instead of waiting on the previous one to end. + private async pumpVideoStream(stream: ReadableStream): Promise { + const reader = stream.getReader() + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + if (!value || value.byteLength === 0) continue + this.rxBytes += value.byteLength + this.windowBytes += value.byteLength + this.videoHandler?.(value) + } + } catch (err) { + if (!this.closed) console.warn('[transport] video stream error', err) + } finally { + try { + reader.releaseLock() + } catch { + /* already released */ + } + } + } + private tickStats(): void { const now = performance.now() const dt = (now - this.lastWindow) / 1000