Files
Piik/internal/client/nativecapture/stream.go
T
AAA da47189fc3 refactor: unify Go runtime and audited frontend
Squash integration of the reviewed Go core, frontend audit fixes, release tooling, and deployment hygiene. CI passed all Server/Client jobs.
2026-09-06 23:26:01 +08:00

339 lines
8.8 KiB
Go

package nativecapture
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
)
const (
captureStopTimeout = time.Second
maxPreviewBytes = 192 * 1024
)
type audioCaptureState struct {
State string `json:"state"`
Audio bool `json:"audio"`
}
type CaptureTarget struct {
Kind string `json:"kind"`
SourceID string `json:"sourceId"`
PID uint32 `json:"pid,omitempty"`
CreationTime string `json:"creationTime,omitempty"`
Title string `json:"title"`
}
type VideoOptions struct {
Target CaptureTarget
Codec string
AdapterIndex uint32
EncoderIndex uint32
Profile VideoProfile
RestoreToken string
}
type VideoProfile struct {
Width uint32
Height uint32
Framerate uint32
Bitrate uint32
Preference string
}
func (profile VideoProfile) Valid() bool {
validResolution :=
(profile.Width == 854 && profile.Height == 480) ||
(profile.Width == 1280 && profile.Height == 720) ||
(profile.Width == 1920 && profile.Height == 1080) ||
(profile.Width == 2560 && profile.Height == 1440)
validPreference := profile.Preference == "maintain-resolution" ||
profile.Preference == "balanced" ||
profile.Preference == "maintain-framerate"
return validResolution && profile.Framerate >= 15 && profile.Framerate <= 60 &&
profile.Bitrate >= 2_000_000 && profile.Bitrate <= 12_000_000 &&
validPreference
}
type Stream struct {
cancel context.CancelFunc
input io.ReadCloser
key io.WriteCloser
done chan error
closeOnce sync.Once
}
func ListSources(parent context.Context, executable string) ([]CaptureTarget, error) {
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
stdout := &boundedBuffer{limit: maxProbeOutputBytes}
command := exec.CommandContext(ctx, executable, "--list")
command.Stdout = stdout
command.Stderr = &boundedBuffer{limit: maxProbeErrorBytes}
hideWindow(command)
if err := command.Run(); err != nil {
return nil, errors.New("native capture source list is unavailable")
}
var targets []CaptureTarget
if err := decodeStrictJSON(stdout.Bytes(), &targets); err != nil || len(targets) > 1024 {
return nil, errors.New("native capture source list is invalid")
}
for _, target := range targets {
if !validCaptureTarget(target) {
return nil, errors.New("native capture target is invalid")
}
}
return targets, nil
}
func PreviewSource(parent context.Context, executable string, target CaptureTarget) ([]byte, error) {
if !validCaptureTarget(target) {
return nil, errors.New("native preview target is invalid")
}
ctx, cancel := context.WithTimeout(parent, probeTimeout)
defer cancel()
stdout := &boundedBuffer{limit: maxPreviewBytes}
command := exec.CommandContext(ctx, executable,
"--preview",
target.Kind,
target.SourceID,
strconv.FormatUint(uint64(target.PID), 10),
zeroWhenEmpty(target.CreationTime),
)
command.Stdout = stdout
command.Stderr = &boundedBuffer{limit: maxProbeErrorBytes}
hideWindow(command)
if err := command.Run(); err != nil {
return nil, errors.New("native capture preview is unavailable")
}
preview := stdout.Bytes()
if len(preview) < 54 || preview[0] != 'B' || preview[1] != 'M' {
return nil, errors.New("native capture preview is invalid")
}
return append([]byte(nil), preview...), nil
}
func StartVideo(parent context.Context, executable string, options VideoOptions) (*Stream, error) {
if !validCaptureTarget(options.Target) || !options.Profile.Valid() ||
(options.Codec != "auto" && options.Codec != "h264" && options.Codec != "vp8") ||
len(options.RestoreToken) > 4096 || !utf8.ValidString(options.RestoreToken) ||
strings.ContainsRune(options.RestoreToken, 0) {
return nil, errors.New("native video target is invalid")
}
environment := []string(nil)
if options.RestoreToken != "" {
environment = []string{"SCREENER_XDP_RESTORE_TOKEN=" + options.RestoreToken}
}
return startStreamWithEnvironment(parent, executable, []string{
"--capture-video",
options.Target.Kind,
options.Target.SourceID,
strconv.FormatUint(uint64(options.Target.PID), 10),
zeroWhenEmpty(options.Target.CreationTime),
"--adapter-index",
strconv.FormatUint(uint64(options.AdapterIndex), 10),
"--mft-index",
strconv.FormatUint(uint64(options.EncoderIndex), 10),
"--width",
strconv.FormatUint(uint64(options.Profile.Width), 10),
"--height",
strconv.FormatUint(uint64(options.Profile.Height), 10),
"--fps",
strconv.FormatUint(uint64(options.Profile.Framerate), 10),
"--bitrate",
strconv.FormatUint(uint64(options.Profile.Bitrate), 10),
"--preference",
options.Profile.Preference,
"--codec",
options.Codec,
"--protocol-v4",
}, environment)
}
func StartAudio(parent context.Context, executable string, target CaptureTarget) (*Stream, error) {
if !validCaptureTarget(target) {
return nil, errors.New("native audio target is invalid")
}
return startAudioStream(parent, executable, []string{
"--capture-audio",
target.Kind,
strconv.FormatUint(uint64(target.PID), 10),
zeroWhenEmpty(target.CreationTime),
})
}
func StartSystemAudio(parent context.Context, executable string) (*Stream, error) {
return startAudioStream(parent, executable, []string{
"--capture-audio", "display", "0", "0",
})
}
func startAudioStream(parent context.Context, executable string, arguments []string) (*Stream, error) {
if parent == nil {
parent = context.Background()
}
stream, err := startStream(parent, executable, arguments)
if err != nil {
return nil, err
}
ready := make(chan error, 1)
go func() {
frame, readErr := stream.Read()
if readErr != nil {
ready <- errors.New("native audio capture did not become ready")
return
}
ready <- validateAudioReadyFrame(frame)
}()
timer := time.NewTimer(probeTimeout)
defer timer.Stop()
select {
case readyErr := <-ready:
if readyErr == nil {
return stream, nil
}
_ = stream.Close()
return nil, readyErr
case <-timer.C:
_ = stream.Close()
return nil, errors.New("native audio capture timed out during startup")
case <-parent.Done():
_ = stream.Close()
return nil, parent.Err()
}
}
func validCaptureTarget(target CaptureTarget) bool {
if !positiveDecimal(target.SourceID) || len(target.Title) == 0 ||
len(target.Title) > 4096 {
return false
}
switch target.Kind {
case "window":
return target.PID > 0 && positiveDecimal(target.CreationTime)
case "display":
return target.PID == 0 && target.CreationTime == ""
case "picker":
return target.PID == 0 && target.CreationTime == ""
default:
return false
}
}
func zeroWhenEmpty(value string) string {
if value == "" {
return "0"
}
return value
}
func validateAudioReadyFrame(frame Frame) error {
var state audioCaptureState
if frame.Kind != FrameStatus || decodeStrictJSON(frame.Data, &state) != nil ||
state.State != "active" || !state.Audio {
return errors.New("native audio capture returned an invalid ready state")
}
return nil
}
func positiveDecimal(value string) bool {
if value == "" || len(value) > 20 || value[0] == '0' {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
_, err := strconv.ParseUint(value, 10, 64)
return err == nil
}
func startStream(parent context.Context, executable string, arguments []string) (*Stream, error) {
return startStreamWithEnvironment(parent, executable, arguments, nil)
}
func startStreamWithEnvironment(
parent context.Context,
executable string,
arguments []string,
environment []string,
) (*Stream, error) {
if parent == nil {
parent = context.Background()
}
if executable == "" {
return nil, errors.New("native capture process is unavailable")
}
ctx, cancel := context.WithCancel(parent)
command := exec.CommandContext(ctx, executable, arguments...)
if len(environment) > 0 {
command.Env = append(os.Environ(), environment...)
}
stdout, err := command.StdoutPipe()
if err != nil {
cancel()
return nil, err
}
stdin, err := command.StdinPipe()
if err != nil {
cancel()
return nil, err
}
command.Stderr = &boundedBuffer{limit: maxProbeErrorBytes}
hideWindow(command)
if err = command.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start native capture process: %w", err)
}
stream := &Stream{
cancel: cancel,
input: stdout,
key: stdin,
done: make(chan error, 1),
}
go func() {
stream.done <- command.Wait()
close(stream.done)
}()
return stream, nil
}
func (stream *Stream) Read() (Frame, error) {
return readFrame(stream.input)
}
func (stream *Stream) RequestKeyFrame() error {
_, err := stream.key.Write([]byte{'K'})
return err
}
func (stream *Stream) Done() <-chan error {
return stream.done
}
func (stream *Stream) Close() error {
stream.closeOnce.Do(func() {
_, _ = stream.key.Write([]byte{'Q'})
_ = stream.key.Close()
})
select {
case <-stream.done:
stream.cancel()
return nil
case <-time.After(captureStopTimeout):
stream.cancel()
<-stream.done
return nil
}
}