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

Distance Desktop

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