diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c3c924b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +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 + - 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 + - run: go vet ./... + - run: go build ./... diff --git a/.gitignore b/.gitignore index 30cfb16..4befd88 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,7 @@ env/ # Build directory build/ -# macOS -.DS_STORE \ No newline at end of file +# macOS +.DS_Store +# web node modules +/src/web/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index f747fe8..2e56145 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 @@ -37,14 +37,22 @@ 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. +`start` also accepts optional `codec` and `bitrate`. -**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). +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 @@ -67,17 +75,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" -``` +**Note**: MoQ integration has been removed. The agent now publishes video exclusively over WebTransport unidirectional streams (raw H.264 Annex B). ### Web UI @@ -95,17 +93,26 @@ 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 | +| `--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 -``` -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 +```text +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) + └─ 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 @@ -138,19 +145,21 @@ captured (Unix sockets) 4. Client caches additional fingerprint 5. On next connection, includes both old and new hashes in `serverCertificateHashes` -## MoQ integration +**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. -- `/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. +**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 (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/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..5b69b82 --- /dev/null +++ b/src/backend/backend.go @@ -0,0 +1,121 @@ +// 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, namesSorted()) + } + return b, nil +} + +// Names returns registered backend names sorted. +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) + } + 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..7da7c8c --- /dev/null +++ b/src/backend/captured.go @@ -0,0 +1,378 @@ +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" + "time" +) + +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 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) + 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() + if s.ffmpeg != nil && s.ffmpeg.Process != nil { + _ = 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.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) + } + + if deadline, ok := ctx.Deadline(); ok { + media.SetReadDeadline(deadline) + } + + 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])) + 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) + // 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() + 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 + 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 + } + 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..5914d6b --- /dev/null +++ b/src/backend/sunshine.go @@ -0,0 +1,109 @@ +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" +) + +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 _, _, err := net.SplitHostPort(addr); err != nil { + 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..87c47e3 --- /dev/null +++ b/src/backend/vnc.go @@ -0,0 +1,71 @@ +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() + if deadline, ok := ctx.Deadline(); ok { + conn.SetDeadline(deadline) + } + 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..d213e79 100644 --- a/src/main.go +++ b/src/main.go @@ -2,7 +2,6 @@ package main import ( "context" - _ "embed" "crypto/tls" "flag" "fmt" @@ -11,16 +10,89 @@ 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) { + 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(entry.name) + if err != nil { + log.Fatalf("%v", err) + } + addr := parseKV(entry.opts)["addr"] + switch t := b.(type) { + case *backend.SunshineBackend: + t.Addr = addr + case *backend.VNCBackend: + t.Addr = addr + case *backend.RDPBackend: + t.Addr = addr + } + } +} + +// selectBackend resolves the requested backend, probing candidates in auto order. +func selectBackend(name string) 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)") @@ -28,8 +100,35 @@ func main() { 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") + 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) + sel := selectBackend(*backendName) + + 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 +155,37 @@ func main() { } wtUpgrader := &webtransport.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, - ApplicationProtocols: []string{"moq-lite-04"}, + 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 + } + } + // 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 + }, + ApplicationProtocols: []string{"moq-lite-04"}, // kept for legacy client compat } wtServer := &webtransport.Server{ @@ -71,26 +199,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 +212,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 +249,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..381f70a 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,33 @@ 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") - } - - if bitrate > 0 { - args = append(args, "-b:v", strconv.Itoa(bitrate)) + b := activeBackend + if b == nil { + var err error + b, err = backend.Get("captured") + if err != nil { + return err + } + activeBackend = b } - args = append(args, "-") - - cmd := exec.Command("ffmpeg", args...) - stdin, err := cmd.StdinPipe() - if err != nil { - ctrl.Close() - media.Close() - return fmt.Errorf("ffmpeg stdin: %w", err) + req := backend.StartRequest{ + DisplayID: uint32(displayID), + FPS: fps, + Codec: codec, + Bitrate: bitrate, } - stdout, err := cmd.StdoutPipe() + startCtx, startCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer startCancel() + stream, err := b.StartStream(startCtx, req) if err != nil { - stdin.Close() - ctrl.Close() - media.Close() - return fmt.Errorf("ffmpeg stdout: %w", err) + return fmt.Errorf("%s start-stream: %w", b.Name(), err) } - cmd.Stderr = os.Stderr - - if err := cmd.Start(); err != nil { - stdin.Close() - ctrl.Close() - media.Close() - return fmt.Errorf("ffmpeg start: %w", 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 +63,33 @@ 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) { + defer func() { + stateMu.Lock() + if state == ss { + teardown() } + stateMu.Unlock() + }() + 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,104 +100,42 @@ 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() } - 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..93a438a 100644 --- a/src/types.go +++ b/src/types.go @@ -2,15 +2,25 @@ package main import ( "context" - "io" - "net" - "os/exec" + "strings" "sync" - "github.com/qumo-dev/gomoqt/moqt" "github.com/okdaichi/webtransport-go" + + "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 ctrl *webtransport.Stream @@ -18,30 +28,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.go b/src/web.go index 9980b28..0697e59 100644 --- a/src/web.go +++ b/src/web.go @@ -1,19 +1,34 @@ package main import ( - _ "embed" + "crypto/tls" + "embed" "encoding/json" + "io/fs" "log" "net" "net/http" + "strings" + "time" ) +//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 +37,41 @@ 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"}, + MinVersion: tls.VersionTLS12, + } + srv := &http.Server{ + 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 { log.Printf("web ui: %v", err) } } diff --git a/src/web/dist/assets/index-BGalDxia.css b/src/web/dist/assets/index-BGalDxia.css new file mode 100644 index 0000000..ddc04b4 --- /dev/null +++ b/src/web/dist/assets/index-BGalDxia.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/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 new file mode 100644 index 0000000..b95b609 --- /dev/null +++ b/src/web/dist/index.html @@ -0,0 +1,28 @@ + + + + + + + Distance Desktop + + + + +
+ +
+ + + + + +
+ + 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-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 new file mode 100644 index 0000000..bcf1cda --- /dev/null +++ b/src/web/package.json @@ -0,0 +1,19 @@ +{ + "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.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..a6d11f1 --- /dev/null +++ b/src/web/src/decoder.ts @@ -0,0 +1,385 @@ +/** + * 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 -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: 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 measuredFps = 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') + } + } + + /** + * 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 + } + + /** 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) + } + } + + /** + * 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 + } + + 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) + } + } + this.pendingAU.push(nal) + } + + 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) + } + + 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` + ) + } + } + + 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 + } + 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: 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) + } + } + } + + /** + * 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.measuredFps = Math.round(this.frameCount / dt) + this.frameCount = 0 + this.fpsWindowStart = now + this.onFps?.(this.measuredFps) + } + } + + get currentFps(): number { + return this.measuredFps + } + + reset(): void { + this.buf = new Uint8Array(0) + this.pendingAU = [] + this.pendingBeforeConfig = [] + this.sps = null + this.pps = null + 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 */ } + this.videoDecoder = null + this.configured = false + } +} diff --git a/src/web/src/input.ts b/src/web/src/input.ts new file mode 100644 index 0000000..d6ac788 --- /dev/null +++ b/src/web/src/input.ts @@ -0,0 +1,180 @@ +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 }) + }) + + // 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 }) + }) + + 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() + const kb = (navigator as Navigator & { keyboard?: { unlock?: () => void } }).keyboard + if (kb?.unlock) { + kb.unlock() + } + } + + 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..8664aaa --- /dev/null +++ b/src/web/src/main.ts @@ -0,0 +1,127 @@ +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' + +// 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 +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, REQUESTED_FPS) + 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() + toast(`Streaming ${msg.width}×${msg.height}`, 'ok') +} + +function onStreamEnded(kind: string): void { + transport.close() + 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, { fps: REQUESTED_FPS }) + }, + 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..fa8bec9 --- /dev/null +++ b/src/web/src/transport.ts @@ -0,0 +1,265 @@ +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 { + 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') + } + + 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) + }, + () => { + 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).catch(() => { + if (!this.closed) this.msgHandler?.({ type: 'stream-ended' } as ServerMessage) + }) + } + + 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) + this.statsTimer = undefined + try { + this.ctrlWriter?.close() + } catch { + /* ignore */ + } + try { + this.wt?.close() + } catch { + /* ignore */ + } + this.wt = null + this.ctrlWriter = null + this.ctrlReader = null + } + + 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 + // 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 + 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..33cfe15 --- /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 +} + +// 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' } + | { + type: 'start' + display_id: number + fps?: number + codec?: string + bitrate?: number + } + | { type: 'stop' } + | InputMessage + +// 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..e64609a --- /dev/null +++ b/src/web/src/ui/connect.ts @@ -0,0 +1,347 @@ +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) { + 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) + }) + grid.append(tile) + } + card.append(grid) + this.root.append(card) + } + + private applyJson(text: string): boolean { + 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) + 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) => { + if (!modal.isConnected) { + stream.getTracks().forEach((t) => t.stop()) + return + } + 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/style.css b/src/web/style.css new file mode 100644 index 0000000..5d3eca5 --- /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..176222e --- /dev/null +++ b/src/web/vite-env.d.ts @@ -0,0 +1,20 @@ +/// + +// 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 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 + } +})