openvibers/openvibe.link Commits
Node: the live screen — screen_stream publishes the machine's screen to its owner's private stream (v0.12.0) (#49)
Changes
@@@ -45,6 +45,8 @@ on a community profile runs the same check and warns when it fails. Install a co**Your computer for your agents:** `--computer observe|ask|trusted|full` adds the `computer` plugin (terminal, files,screen, mouse and keyboard, and a browser of its own for OpenVibe.Actor), running as your own account, at the level you set on the machine; `ask` waits foryour answer in the tray, a notification's Approve button or `openvibe-node approve <id>` ([docs/computer.md](docs/computer.md)).++With it on, you can also watch the machine's screen live on openvibe.bot. Only you see it, it runs for up to 30++minutes, and `openvibe-node stop` ends it at once ([the live screen](docs/computer.md#the-live-screen-screen_stream-node-v0120)).**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: thejob worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding@@@ -699,6 +699,14 @@ func cmdStatus(g *globals, asJSON bool, stdout io.Writer) error {fmt.Fprintf(stdout, " (%s)", s.Video.LastError)}fmt.Fprintln(stdout)++ if s.Screen.State != "" && s.Screen.State != "off" {++ // Shown only while it runs: the owner sees at a glance that their screen is live.++ fmt.Fprintf(stdout, "Screen: %s to your OpenVibe account until %s, %d frames", s.Screen.State, s.Screen.Until, s.Screen.Frames)++ if s.Screen.LastError != "" && s.Screen.State != "live" {++ fmt.Fprintf(stdout, " (%s)", s.Screen.LastError)++ }++ fmt.Fprintln(stdout, "; `openvibe-node stop` ends it")++ }fmt.Fprintln(stdout, "Plugins:")for _, p := range s.Plugins {fmt.Fprintf(stdout, " %-15s %-9s %s %s", p.Name, p.State, p.Driver, p.Version)@@@ -90,6 +90,24 @@ A program runs in its own process group with the Node account's rights, at mostfrom `screencapture` (macOS), `grim` (Wayland) or `import`, `scrot` or `gnome-screenshot` (X11), so the Node must run inyour graphical session for `screen`; without one the plugin simply does not offer it.++### The live screen (`screen_stream`, Node v0.12.0)++++You can watch your machine's screen live on openvibe.bot (**Your machines** → **Show screen**), for 30 seconds to++30 minutes at a time:++++- **Only you see it.** Bot gives your machine's start command the WHIP address and key of its own private++ OpenRestream stream (no recording, never on Live). No operator or agent supplies them, and agents cannot start it.++- **How it works.** The plugin captures a JPEG frame with the same tools as `screen`, 1 to 5 times a second (2 by++ default), at most 1600 px wide when Pillow is installed. The core encodes the frames (ffmpeg, else its built-in++ encoder) and publishes them on a WebRTC session of their own, separate from a robot's camera.++- **You are told.** The desktop says when it starts ("Your screen is live on OpenVibe"), and `openvibe-node status`++ shows `Screen: live … until …` while it runs.++- **It ends** when its time is up, when you press Stop, when capturing fails five times in a row, and at once on++ `openvibe-node stop` or an e-stop. While a stop is latched, a new start is refused.++- **Levels.** It runs at every level, like a screenshot. To never offer it, set `allow_screen_stream: false`.++- **Without Pillow** the capture tool must write JPEG itself: `screencapture`, `grim`, `import` and `scrot` do;++ `gnome-screenshot` does not.++**Mouse and keyboard** (`input`) is an acting command like running a program: refused at `observe`, approved one by oneat `ask` (the approval says exactly what it would do: "click at 640, 360 of a 1280×720 picture of the screen","type 12 characters: 'hello there'", "press ctrl+s"). Coordinates are in the space of the screen picture the agent last@@@ -121,12 +139,14 @@ text and keys are passed to the tool as arguments, never through a shell, and `k| `probe` | `true` | `false`: no probes (OpenVibe.Watch's `node` source) || `allow_input` | `true` | `false`: never use the mouse or keyboard (no `input` capability) || `allow_browser` | `true` | `false`: no browser (no `browser` capability) |++| `allow_screen_stream` | `true` | `false`: never show the screen live (`screen.stream` is false) || `browser_path` | found on PATH | the browser to drive (Chromium, Chrome, Edge or Brave) || `browser_profile` | `~/.local/share/openvibe-node/browser` | its profile folder: the agent's own, unless you choose another || `browser_headless` | `true` | `false`: show its window || `browser_idle_s` | 600 | close it after this long without a command |`describe` reports `terminal`, `files`, `screen`, `input` and `browser` with the level and folders, so Bot shows what the machine allows;--telemetry carries the sensors `computer_level` (observe while a stop is latched), `computer_waiting` and--`computer_running`.++telemetry carries the sensors `computer_level` (observe while a stop is latched), `computer_waiting`,++`computer_running` and `computer_screen_stream` (`live` or `off`). `screen.stream` says whether the live screen is++offered.Every request is logged by the Node (`journalctl -u openvibe-node`), with what it would do, approved or denied.@@@ -201,6 +201,7 @@ at most every 500 ms (≤ 2 Hz, Bot's cap). There is no separate `event` message| `exec` | `{"argv": ["prog", "arg"…], "cwd"?, "timeout_s"?, "stdin"?}`: run a program on the machine ([computer.md](computer.md)) | computer || `fs` | `{"op": "list"\|"stat"\|"read"\|"write"\|"mkdir", "path", …}`: a file or folder on the machine | computer || `screen` | `{"max_width"?}`: capture the machine's screen | computer |++| `screen_stream` | `{"action": "start", "whip_url", "publish_key", "seconds"?, "fps"?}` or `{"action": "stop"}`: show the screen live on the machine's private stream (Bot fills in the URL and key) | computer || `probe` | `{"target": "http(s)://…" \| "tcp://host:port", "timeout_s"?}`: a read-only check of the machine's own network | computer |Sign conventions: `throttle`/`x` positive = forward; `steer` positive = turn right; `y` positive = strafe left;@@@ -26,6 +26,12 @@ import (// startComputer runs a Node with only the real Python computer plugin at the given control level, its roots the one// folder it returns, against the fake Bot.func startComputer(t *testing.T, level string) (*env, string) {++ t.Helper()++ return startComputerWith(t, level, nil)++}++++// startComputerWith is startComputer with tweak applied to the plugin's config and the Node's options before it starts.++func startComputerWith(t *testing.T, level string, tweak func(*config.PluginConfig, *Options)) (*env, string) {t.Helper()if runtime.GOOS == "windows" {t.Skip("end-to-end tests run on unix")@@@ -66,8 +72,12 @@ func startComputer(t *testing.T, level string) (*env, string) {cfg.Plugins = []config.PluginConfig{{Name: "computer", Config: map[string]any{"level": level, "roots": []string{work}, "timeout_s": 20},Env: map[string]string{"PYTHONPATH": filepath.Join(root, "plugins/sdk") + string(os.PathListSeparator) + filepath.Join(root, "plugins/computer")}}}cfg.Video = config.VideoConfig{Source: "off"}-- n, err := New(Options{Config: cfg, Paths: paths, Creds: creds, Version: "test", HeartbeatInterval: 100 * time.Millisecond,-- LinkBackoffMin: 50 * time.Millisecond, LatchPoll: 50 * time.Millisecond, Log: testLog()})++ opt := Options{Config: cfg, Paths: paths, Creds: creds, Version: "test", HeartbeatInterval: 100 * time.Millisecond,++ LinkBackoffMin: 50 * time.Millisecond, LatchPoll: 50 * time.Millisecond, Log: testLog()}++ if tweak != nil {++ tweak(&opt.Config.Plugins[0], &opt)++ }++ n, err := New(opt)if err != nil {t.Fatal(err)}@@@ -66,6 +66,8 @@ type Node struct {mgr *plugins.Managerlink *link.Linkpub *video.Publisher++ // screen is the owner's live view of the machine's screen (screen.go), apart from a robot's camera.++ screen screenStatectx context.Context@@@ -496,6 +498,10 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, rnack(protocol.FaultBadValue, "value must be a JSON object")return}++ if c.Kind == protocol.KindScreenStream {++ n.screenCommand(c, entry, received, nack)++ return++ }if protocol.ComputerActs(c.Kind, c.Value) {st := n.latch.State()if st.Local {@@@ -670,12 +676,20 @@ func (n *Node) output(o plugins.Output) {fields = nil}n.log.Info("plugin event", "plugin", o.Plugin, "event", name)++ if name == "screen_stream_ended" {++ sid, _ := o.Data["sid"].(string)++ n.screenEnded(o.Plugin, sid)++ }n.trackApproval(o.Plugin, name, o.Data, o.At)n.mu.Lock()n.events = append(n.events, protocol.Event{Name: name, Driver: o.Plugin, TS: o.At.UnixMilli(), Fields: fields})n.mu.Unlock()n.flushTelemetry(true)case plugins.OutVideo:++ if stream, _ := o.Data["stream"].(string); stream == "screen" {++ n.screenFrame(o)++ return++ }n.mu.Lock()j, from := n.jpeg, n.jpegFromn.mu.Unlock()@@@ -875,6 +889,7 @@ type Status struct {Limits safety.Limits `json:"limits"`Plugins []PluginStatus `json:"plugins"`Video VideoStatus `json:"video"`++ Screen ScreenStatus `json:"screen"`ConfigDir string `json:"config_dir"`StateDir string `json:"state_dir"`Telemetry protocol.Telemetry `json:"telemetry"`@@@ -933,6 +948,7 @@ func (n *Node) Status() Status {if n.worker != nil {s.Jobs = n.worker.Jobs()}++ s.Screen = n.screenStatus()s.Video.State = video.StateOffif n.pub != nil {s.Video.State, s.Video.Frames, s.Video.LastError = n.pub.State(), n.pub.Frames(), n.pub.LastError()nnew file mode 100644@@@ -0,0 +1,264 @@++package node++++import (++ "context"++ "encoding/json"++ "fmt"++ "net"++ "net/url"++ "sync"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"++ "github.com/OpenVibers/OpenVibe.Node/internal/plugins"++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"++ "github.com/OpenVibers/OpenVibe.Node/internal/safety"++ "github.com/OpenVibers/OpenVibe.Node/internal/video"++)++++// The screen stream (screen_stream, contracts 0.182.0): the machine's screen shown live to its owner. Bot mints the++// WHIP URL and publish key of the machine's own private OpenRestream stream and sends them in the start command; the++// computer plugin captures the frames (the same tools as `screen`, at the owner's level) and emits them as video++// with stream "screen"; the core encodes and publishes them on a second WHIP session, apart from a robot's camera.++//++// A stream ends when its time is up, on stop, when the plugin says it ended, and at once when the local kill switch++// or an e-stop latches; a start is refused while either is latched. One stream at a time: a new start replaces it.++const (++ ScreenDefaultSeconds = 600++ ScreenMinSeconds = 30++ ScreenMaxSeconds = 1800++ ScreenDefaultFPS = 2++ ScreenMaxFPS = 5++ // The built-in encoder's picture size when ffmpeg is missing (ffmpeg keeps the frames' own size).++ screenFallbackW, screenFallbackH = 960, 544++)++++type screenStream struct {++ pub *video.Publisher++ src *video.JPEGSource++ from string // the plugin whose frames feed it++ sid string // the plugin's id for it: its "ended" event names it++ until time.Time++ cancel context.CancelFunc++ done chan struct{}++}++++type screenState struct {++ mu sync.Mutex++ cur *screenStream++}++++// ScreenStatus is the screen stream in `openvibe-node status`.++type ScreenStatus struct {++ State string `json:"state"`++ Until string `json:"until,omitempty"`++ Frames int64 `json:"frames,omitempty"`++ LastError string `json:"last_error,omitempty"`++}++++type screenValue struct {++ Action string `json:"action"`++ WHIPURL string `json:"whip_url"`++ PublishKey string `json:"publish_key"`++ Seconds *int `json:"seconds"`++ FPS *int `json:"fps"`++}++++// screenCommand answers screen_stream: the plugin is asked first (it applies the owner's level and has the capture++// tool), and only once it said yes does the core publish.++func (n *Node) screenCommand(c protocol.Command, entry *safety.Entry, received time.Time, nack func(code, msg string)) {++ var v screenValue++ if err := json.Unmarshal(c.Value, &v); err != nil {++ nack(protocol.FaultBadValue, "value must be {action, …}")++ return++ }++ p := n.mgr.Route(protocol.KindScreenStream, c.Target, "")++ switch v.Action {++ case "stop":++ stopped := n.stopScreen("stopped")++ if p != nil {++ // Best effort: the plugin stops capturing (its answer does not change the core's).++ wait := p.Begin(n.ctx, c.ID+":stop", protocol.KindScreenStream, json.RawMessage(`{"action":"stop"}`), 5000)++ go wait()++ }++ data, _ := json.Marshal(map[string]any{"streaming": false, "stopped": stopped})++ r := protocol.AckData{ID: c.ID, LatencyMS: time.Since(received).Milliseconds(), Data: data}++ n.dedup.Complete(entry, r)++ n.send(r)++ return++ case "start":++ default:++ nack(protocol.FaultBadValue, "action is start or stop")++ return++ }++ st := n.latch.State()++ if st.Local {++ nack(protocol.FaultLocalStop, "stopped on the machine with `openvibe-node stop`")++ return++ }++ if st.Remote {++ nack(protocol.FaultEstopped, st.RemoteReason)++ return++ }++ if err := n.checkScreenURL(v.WHIPURL); err != nil {++ nack(protocol.FaultBadValue, err.Error())++ return++ }++ if v.PublishKey == "" || len(v.PublishKey) > 256 {++ nack(protocol.FaultBadValue, "publish_key is required")++ return++ }++ seconds, fps := ScreenDefaultSeconds, ScreenDefaultFPS++ if v.Seconds != nil {++ seconds = min(max(*v.Seconds, ScreenMinSeconds), ScreenMaxSeconds)++ }++ if v.FPS != nil {++ fps = min(max(*v.FPS, 1), ScreenMaxFPS)++ }++ if p == nil {++ nack(protocol.FaultUnsupported, "no driver on this device captures the screen (the computer plugin is off)")++ return++ }++ // The plugin never sees the URL or the key.++ toPlugin, _ := json.Marshal(map[string]any{"action": "start", "seconds": seconds, "fps": fps})++ wait := p.Begin(n.ctx, c.ID, protocol.KindScreenStream, toPlugin, 15000)++ key := credentials.NewSecret(v.PublishKey)++ go func() {++ r := wait()++ var msg protocol.Message++ if r.OK {++ var got struct {++ SID string `json:"sid"`++ }++ _ = json.Unmarshal(r.Data, &got)++ until := n.startScreen(v.WHIPURL, key, seconds, fps, p.Name(), got.SID)++ data, _ := json.Marshal(map[string]any{"streaming": true, "seconds": seconds, "fps": fps, "until_ms": until.UnixMilli()})++ msg = protocol.AckData{ID: c.ID, LatencyMS: time.Since(received).Milliseconds(), Data: data}++ } else {++ msg = protocol.Nack{ID: c.ID, FaultCode: r.FaultCode, Message: r.Message}++ }++ n.dedup.Complete(entry, msg)++ n.send(msg)++ }()++}++++// checkScreenURL accepts an https WHIP endpoint with no credentials in it (http only to loopback, for tests).++func (n *Node) checkScreenURL(raw string) error {++ u, err := url.Parse(raw)++ if err != nil || u.Host == "" || u.User != nil || len(raw) > 2048 {++ return fmt.Errorf("whip_url must be an https URL")++ }++ switch u.Scheme {++ case "https":++ return nil++ case "http":++ if ip := net.ParseIP(u.Hostname()); n.opt.VideoLoopback && ip != nil && ip.IsLoopback() {++ return nil++ }++ }++ return fmt.Errorf("whip_url must be an https URL")++}++++// startScreen publishes the plugin's screen frames until the time is up (replacing a stream that runs) → the end.++func (n *Node) startScreen(whip string, key credentials.Secret, seconds, fps int, from, sid string) time.Time {++ n.stopScreen("replaced")++ ctx, cancel := context.WithCancel(n.ctx)++ until := time.Now().Add(time.Duration(seconds) * time.Second)++ src := &video.JPEGSource{FFmpeg: n.opt.Config.Video.FFmpeg, FPS: fps, W: screenFallbackW, H: screenFallbackH, Log: n.log}++ pub := video.NewPublisher(video.Options{WHIPURL: whip, PublishKey: key, ICEServers: n.screenICE(), Log: n.log, IncludeLoopback: n.opt.VideoLoopback})++ s := &screenStream{pub: pub, src: src, from: from, sid: sid, until: until, cancel: cancel, done: make(chan struct{})}++ n.screen.mu.Lock()++ n.screen.cur = s++ n.screen.mu.Unlock()++ n.log.Info("screen stream started", "seconds", seconds, "fps", fps)++ go func() {++ defer close(s.done)++ pub.Run(ctx, src)++ }()++ go func() {++ t := time.NewTicker(250 * time.Millisecond)++ defer t.Stop()++ for {++ select {++ case <-ctx.Done():++ return++ case now := <-t.C:++ st := n.latch.State()++ switch {++ case st.Local || st.Remote:++ n.endScreen(s, "stopped on the machine")++ return++ case now.After(until.Add(3 * time.Second)):++ n.endScreen(s, "time is up")++ return++ }++ }++ }++ }()++ return until++}++++func (n *Node) screenICE() []protocol.ICEServer {++ if n.opt.Creds == nil {++ return nil++ }++ return n.opt.Creds.ICEServers++}++++// endScreen ends s if it is still the running stream.++func (n *Node) endScreen(s *screenStream, why string) {++ n.screen.mu.Lock()++ if n.screen.cur != s {++ n.screen.mu.Unlock()++ return++ }++ n.screen.cur = nil++ n.screen.mu.Unlock()++ s.cancel()++ n.log.Info("screen stream ended", "why", why)++}++++// stopScreen ends the running stream, if any → whether one ran.++func (n *Node) stopScreen(why string) bool {++ n.screen.mu.Lock()++ s := n.screen.cur++ n.screen.mu.Unlock()++ if s == nil {++ return false++ }++ n.endScreen(s, why)++ return true++}++++// screenEnded ends the stream the plugin says it ended (by its id), never a newer one.++func (n *Node) screenEnded(plugin, sid string) {++ n.screen.mu.Lock()++ s := n.screen.cur++ n.screen.mu.Unlock()++ if s != nil && s.from == plugin && s.sid != "" && s.sid == sid {++ n.endScreen(s, "the plugin ended it")++ }++}++++// screenFrame passes a plugin's screen frame to the running stream (frames from any other plugin are dropped).++func (n *Node) screenFrame(o plugins.Output) {++ n.screen.mu.Lock()++ s := n.screen.cur++ n.screen.mu.Unlock()++ if s == nil || s.from != o.Plugin {++ return++ }++ if p, ok := o.Data["path"].(string); ok {++ s.src.Push(p)++ }++}++++func (n *Node) screenStatus() ScreenStatus {++ n.screen.mu.Lock()++ s := n.screen.cur++ n.screen.mu.Unlock()++ if s == nil {++ return ScreenStatus{State: video.StateOff}++ }++ return ScreenStatus{State: s.pub.State(), Until: s.until.UTC().Format(time.RFC3339), Frames: s.pub.Frames(), LastError: s.pub.LastError()}++}nnew file mode 100644@@@ -0,0 +1,140 @@++//go:build !windows++++package node++++import (++ "image"++ "image/color"++ "image/jpeg"++ "net/http/httptest"++ "os"++ "path/filepath"++ "testing"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/config"++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"++ "github.com/OpenVibers/OpenVibe.Node/internal/video"++)++++// startScreenNode runs the real computer plugin with a fake `grim` (a Wayland session) that copies a real JPEG to the++// file it is given, so the screen stream has frames without a display; video may use loopback.++func startScreenNode(t *testing.T, level string) *env {++ t.Helper()++ bin := t.TempDir()++ pic := filepath.Join(bin, "screen.jpg")++ img := image.NewRGBA(image.Rect(0, 0, 320, 240))++ for y := 0; y < 240; y++ {++ for x := 0; x < 320; x++ {++ img.Set(x, y, color.RGBA{uint8(x), uint8(y), 160, 255})++ }++ }++ f, err := os.Create(pic)++ if err != nil {++ t.Fatal(err)++ }++ if err := jpeg.Encode(f, img, nil); err != nil {++ t.Fatal(err)++ }++ f.Close()++ // grim [-t jpeg -q N] <out>: the output is the last argument.++ script := "#!/bin/sh\nfor last; do :; done\ncp \"" + pic + "\" \"$last\"\n"++ if err := os.WriteFile(filepath.Join(bin, "grim"), []byte(script), 0o755); err != nil {++ t.Fatal(err)++ }++ e, _ := startComputerWith(t, level, func(pc *config.PluginConfig, o *Options) {++ pc.Env["PATH"] = bin + string(os.PathListSeparator) + os.Getenv("PATH")++ pc.Env["WAYLAND_DISPLAY"] = "wayland-test"++ pc.Config["notify"] = false++ o.VideoLoopback = true++ })++ return e++}++++func screenWHIP(t *testing.T) (*video.WHIPReceiver, string) {++ t.Helper()++ w := &video.WHIPReceiver{PublishKey: "ork_screen_test_key"}++ srv := httptest.NewServer(w)++ t.Cleanup(func() { w.Close(); srv.Close() })++ return w, srv.URL + "/whip/ork_screen_test_key"++}++++func TestScreenStreamPublishesTheScreenAndStops(t *testing.T) {++ e := startScreenNode(t, "observe")++ whip, url := screenWHIP(t)++ r := e.computerCmd("s1", protocol.KindScreenStream, map[string]any{"action": "start", "whip_url": url, "publish_key": "ork_screen_test_key", "seconds": 60, "fps": 5}, 20*time.Second)++ d := dataOf(t, r)++ if d["streaming"] != true || d["seconds"] != float64(60) || d["fps"] != float64(5) {++ t.Fatalf("%+v", d)++ }++ deadline := time.Now().Add(20 * time.Second)++ for whip.Packets.Load() < 10 {++ if time.Now().After(deadline) {++ t.Fatalf("no screen video: %+v", e.node.Status().Screen)++ }++ time.Sleep(50 * time.Millisecond)++ }++ if st := e.node.Status().Screen; st.State != video.StateLive || st.Until == "" {++ t.Fatalf("status %+v", st)++ }++ if whip.BadAuth.Load() != 0 {++ t.Fatal("the publish key was not sent as the bearer")++ }++ stop := dataOf(t, e.computerCmd("s2", protocol.KindScreenStream, map[string]any{"action": "stop"}, 10*time.Second))++ if stop["stopped"] != true {++ t.Fatalf("%+v", stop)++ }++ deadline = time.Now().Add(5 * time.Second)++ for e.node.Status().Screen.State != video.StateOff || whip.Deletes.Load() == 0 {++ if time.Now().After(deadline) {++ t.Fatalf("still streaming after stop: %+v (deletes %d)", e.node.Status().Screen, whip.Deletes.Load())++ }++ time.Sleep(50 * time.Millisecond)++ }++}++++func TestScreenStreamEndsOnTheKillSwitchAndIsRefusedWhileLatched(t *testing.T) {++ e := startScreenNode(t, "trusted")++ whip, url := screenWHIP(t)++ start := map[string]any{"action": "start", "whip_url": url, "publish_key": "ork_screen_test_key", "fps": 5}++ dataOf(t, e.computerCmd("k1", protocol.KindScreenStream, start, 20*time.Second))++ deadline := time.Now().Add(20 * time.Second)++ for whip.Packets.Load() < 5 {++ if time.Now().After(deadline) {++ t.Fatalf("no screen video: %+v", e.node.Status().Screen)++ }++ time.Sleep(50 * time.Millisecond)++ }++ if _, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStop}, 2*time.Second); err != nil {++ t.Fatal(err)++ }++ deadline = time.Now().Add(3 * time.Second)++ for e.node.Status().Screen.State != video.StateOff {++ if time.Now().After(deadline) {++ t.Fatalf("the kill switch did not end the stream: %+v", e.node.Status().Screen)++ }++ time.Sleep(25 * time.Millisecond)++ }++ if c := nackCode(e.computerCmd("k2", protocol.KindScreenStream, start, 10*time.Second)); c != protocol.FaultLocalStop {++ t.Fatalf("start while latched: %q", c)++ }++}++++func TestScreenStreamRefusesBadTargets(t *testing.T) {++ e := startScreenNode(t, "observe")++ for i, v := range []map[string]any{++ {"action": "start", "whip_url": "http://203.0.113.5/whip/k", "publish_key": "k"},++ {"action": "start", "whip_url": "https://user:[email protected]/whip/k", "publish_key": "k"},++ {"action": "start", "whip_url": "https://openre.stream/whip/k"},++ {"action": "watch"},++ } {++ r := e.computerCmd("b"+string(rune('0'+i)), protocol.KindScreenStream, v, 10*time.Second)++ if c := nackCode(r); c != protocol.FaultBadValue {++ t.Fatalf("%v: %q", v, c)++ }++ }++ if st := e.node.Status().Screen.State; st != video.StateOff {++ t.Fatalf("%q", st)++ }++}@@@ -10,19 +10,20 @@ import (// capabilityFor maps a command kind to the describe capability key that declares it.var capabilityFor = map[string]string{-- protocol.KindDrive: "drive",-- protocol.KindPTZ: "ptz",-- protocol.KindActuator: "actuator",-- protocol.KindSay: "say",-- protocol.KindDisplay: "display",-- protocol.KindButton: "button",-- protocol.KindPoint: "point",-- protocol.KindExec: "terminal",-- protocol.KindFS: "files",-- protocol.KindScreen: "screen",-- protocol.KindProbe: "probe",-- protocol.KindInput: "input",-- protocol.KindBrowser: "browser",++ protocol.KindDrive: "drive",++ protocol.KindPTZ: "ptz",++ protocol.KindActuator: "actuator",++ protocol.KindSay: "say",++ protocol.KindDisplay: "display",++ protocol.KindButton: "button",++ protocol.KindPoint: "point",++ protocol.KindExec: "terminal",++ protocol.KindFS: "files",++ protocol.KindScreen: "screen",++ protocol.KindScreenStream: "screen",++ protocol.KindProbe: "probe",++ protocol.KindInput: "input",++ protocol.KindBrowser: "browser",}// Manager supervises every configured plugin.@@@ -170,13 +170,18 @@ const (// KindBrowser drives a Chromium the agent uses, in a profile of its own (contracts 0.174.0): open, click, fill,// press and back act (the owner's level decides, a latched stop refuses them); read, screenshot and close do not.KindBrowser = "browser"++ // KindScreenStream shows the machine's screen live to its owner (contracts 0.182.0): {"action": "start",++ // "whip_url", "publish_key", "seconds"?, "fps"?} or {"action": "stop"}. Bot fills in the WHIP URL and key (the++ // machine's private OpenRestream stream); no operator sends them. The computer plugin captures the frames, the++ // core publishes them; a latched stop ends it and refuses a start.++ KindScreenStream = "screen_stream")// Kinds lists every command kind the Node understands.--var Kinds = []string{KindDrive, KindActuator, KindPTZ, KindSay, KindDisplay, KindHalt, KindButton, KindPoint, KindExec, KindFS, KindScreen, KindProbe, KindInput, KindBrowser}++var Kinds = []string{KindDrive, KindActuator, KindPTZ, KindSay, KindDisplay, KindHalt, KindButton, KindPoint, KindExec, KindFS, KindScreen, KindProbe, KindInput, KindBrowser, KindScreenStream}// ComputerKinds move nothing: they carry long deadlines (DefaultComputerMS, at most MaxComputerMS) and no clamping.--var ComputerKinds = map[string]bool{KindExec: true, KindFS: true, KindScreen: true, KindProbe: true, KindInput: true, KindBrowser: true}++var ComputerKinds = map[string]bool{KindExec: true, KindFS: true, KindScreen: true, KindProbe: true, KindInput: true, KindBrowser: true, KindScreenStream: true}const (// DefaultComputerMS is a computer command's reply window when the server sends no deadline; MaxComputerMS caps@@@ -21,6 +21,12 @@ Command kinds and their answers (the ack's ``data``):* ``fs`` ``{"op": "list"|"stat"|"read"|"write"|"mkdir", "path", …}``: ``read`` takes ``offset``/``length`` and answers``content_b64``; ``write`` takes ``content_b64`` and ``mode`` (``create``, ``overwrite`` or ``append``).* ``screen`` ``{"max_width"?}`` → ``{format, width, height, content_b64}`` (JPEG through Pillow when it is installed).++* ``screen_stream`` ``{"action": "start", "seconds"?, "fps"?}`` → ``{streaming, seconds, fps, tool}``, or ``{"action":++ "stop"}``: the screen shown live to the machine's owner. The plugin captures a JPEG frame ``fps`` times a second (at++ most 5) with the same tools as ``screen`` and hands each to the core as video (``stream: "screen"``), which publishes++ it to the owner's private stream; the core alone holds the WHIP URL and key. It runs at every level, like a++ screenshot, says so on the desktop when it starts (config ``notify``), and ends when its time is up (30 s to 30 min),++ on stop, and at once on the local kill switch or an e-stop. Config ``allow_screen_stream: false`` turns it off.* ``input`` ``{"action", "x"?, "y"?, "width"?, "height"?, "dy"?, "text"?, "keys"?}`` → ``{done, action, x?, y?}``: move,click, double_click or right_click at a point, scroll by ``dy`` clicks, type ``text`` into whatever has focus, or pressone key or chord (``keys``, for example ``ctrl+s``). ``x``/``y`` are in the space of a screen picture ``width`` ×@@@ -79,6 +85,11 @@ INPUT_GUARD_S = 2.0MAX_OUTPUT_CAP = 192 * 1024MAX_READ_CAP = 256 * 1024SCREEN_MAX_BYTES = 300 * 1024++# The live screen: frame width (Pillow resizes larger screens) and JPEG quality; the stream ends after this many++# capture failures in a row.++STREAM_WIDTH = 1600++STREAM_QUALITY = 70++STREAM_MAX_FAILURES = 5INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scroll", "type", "key"})BROWSER_READS = frozenset({"read", "screenshot", "close"})BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back"})@@@ -121,7 +132,7 @@ def _int(v, name, lo, hi, default):class Computer(Plugin):driver = "computer"-- version = "0.6.0"++ version = "0.7.0"motion_kinds = frozenset()telemetry_interval_s = 2.0@@@ -158,6 +169,9 @@ class Computer(Plugin):self.browser_headless = Trueself.browser_idle_s = 600self.browser = None++ self.allow_screen_stream = True++ self.stream = None # the live screen: {"sid", "until", "fps", "tool", "dir", "stop": Event}++ self.stream_mu = threading.Lock()self.browser_mu = threading.Lock()self.browser_op_mu = threading.Lock()self.browser_factory = browser_factory or (lambda binary, profile, headless: Browser(binary, profile, headless))@@@ -184,6 +198,7 @@ class Computer(Plugin):self.allow_input = config.get("allow_input", True) is not Falseself.notify = config.get("notify", True) is not Falseself.allow_browser = config.get("allow_browser", True) is not False++ self.allow_screen_stream = config.get("allow_screen_stream", True) is not Falseself.browser_path = config.get("browser_path") or Noneself.browser_profile = os.path.expanduser(config.get("browser_profile") or "~/.local/share/openvibe-node/browser")self.browser_headless = config.get("browser_headless", True) is not False@@@ -199,7 +214,8 @@ class Computer(Plugin):caps["terminal"] = {"level": self.level, "timeout_s": self.timeout_s, "max_output_bytes": self.max_output}tool = self._screen_tool()if tool:-- caps["screen"] = {"level": self.level, "tool": tool[0]}++ caps["screen"] = {"level": self.level, "tool": tool[0],++ "stream": bool(self.allow_screen_stream and self._stream_capable(tool))}if self.allow_probe:caps["probe"] = {"lan_only": True, "schemes": ["http", "https", "tcp"]}itool = self._input_tool() if self.allow_input else None@@@ -231,6 +247,7 @@ class Computer(Plugin):for p in procs:self._kill(p)self._close_browser()++ self._end_stream("stopped on the machine")for cid in waiting:self._close_prompt(cid)self.emit_event("approval_done", id=cid, approved=False, reason="stopped")@@@ -262,7 +279,9 @@ class Computer(Plugin):self.emit_event("approval_done", id=cid, approved=False, reason="expired")self.fail(cid, "not_allowed", "nobody answered on the machine in time")# Sensors are what the core forwards to Bot: the machines page shows the level and what waits on the machine.-- return {"sensors": {"computer_level": self.effective_level(), "computer_waiting": waiting, "computer_running": running}}++ streaming = self.stream is not None++ return {"sensors": {"computer_level": self.effective_level(), "computer_waiting": waiting, "computer_running": running,++ "computer_screen_stream": "live" if streaming else "off"}}# ---- paths -----------------------------------------------------------------------------------------------------def _inside(self, path):@@@ -296,6 +315,8 @@ class Computer(Plugin):raise Fault("unsupported", "no screen capture tool on this machine")width = _int(v.get("max_width"), "max_width", 320, 3840, 1280)return self._start(cmd, lambda: self._screen(tool, width))++ if cmd.kind == "screen_stream":++ return self._screen_stream(v)if cmd.kind == "fs":op = v.get("op")if op in READ_OPS:@@@ -1041,6 +1062,149 @@ class Computer(Plugin):if os.path.exists(out):os.unlink(out)++ # ---- the live screen --------------------------------------------------------------------------------------------++ @staticmethod++ def _have_pillow():++ try:++ import PIL.Image # noqa: F401++ except ImportError:++ return False++ return True++++ @staticmethod++ def _native_jpeg(tool, path):++ """The capture tool writing JPEG itself (for machines without Pillow), or None when it only writes PNG."""++ name = tool[0]++ if name == "screencapture":++ return ["screencapture", "-x", "-t", "jpg", path]++ if name == "grim":++ return ["grim", "-t", "jpeg", "-q", str(STREAM_QUALITY), path]++ if name == "import":++ return ["import", "-silent", "-window", "root", "-quality", str(STREAM_QUALITY), path]++ if name == "scrot":++ return ["scrot", "-o", "-q", str(STREAM_QUALITY), path]++ return None++++ def _stream_capable(self, tool):++ return self._have_pillow() or self._native_jpeg(tool, "x.jpg") is not None++++ def _screen_stream(self, v):++ action = v.get("action")++ if action == "stop":++ return {"streaming": False, "stopped": self._end_stream("stopped by its owner")}++ if action != "start":++ raise Fault("bad_value", "action is start or stop")++ if not self.allow_screen_stream:++ raise Fault("unsupported", "this machine does not show its screen live (allow_screen_stream is off)")++ if self.runtime is not None and self.runtime.estopped:++ raise Fault("local_stop", "stopped on the machine")++ tool = self._screen_tool()++ if not tool:++ raise Fault("unsupported", "no screen capture tool on this machine")++ if not self._stream_capable(tool):++ raise Fault("unsupported", "%s only writes PNG: install Pillow (pip install pillow) to show the screen live" % tool[0])++ seconds = _int(v.get("seconds"), "seconds", 30, 1800, 600)++ fps = _int(v.get("fps"), "fps", 1, 5, 2)++ self._end_stream("replaced")++ frames = tempfile.mkdtemp(prefix="openvibe-screen-stream-") # 0700, this account only++ stream = {"sid": os.urandom(8).hex(), "until": self.clock() + seconds, "fps": fps, "tool": tool, "dir": frames,++ "stop": threading.Event()}++ with self.stream_mu:++ self.stream = stream++ threading.Thread(target=self._stream_loop, args=(stream,), name="computer-screen-stream", daemon=True).start()++ self._notify_stream(seconds)++ return {"streaming": True, "sid": stream["sid"], "seconds": seconds, "fps": fps, "tool": tool[0]}++++ def _end_stream(self, reason):++ """End the live screen, if one runs → whether one did."""++ with self.stream_mu:++ stream, self.stream = self.stream, None++ if stream is None:++ return False++ stream["stop"].set()++ stream["reason"] = reason++ return True++++ def _stream_loop(self, stream):++ period = 1.0 / stream["fps"]++ n, failures, reason = 0, 0, "time is up"++ try:++ while not stream["stop"].is_set():++ started = self.clock()++ if started >= stream["until"]:++ break++ path = os.path.join(stream["dir"], "frame-%d.jpg" % (n % 3)) # the core reads each one at once++ try:++ self._stream_frame(stream["tool"], path)++ n += 1++ failures = 0++ self.emit_video(path, stream="screen")++ except Fault as f:++ failures += 1++ if failures >= STREAM_MAX_FAILURES:++ reason = "the screen capture failed: %s" % f.message++ break++ stream["stop"].wait(max(0.0, period - (self.clock() - started)))++ finally:++ shutil.rmtree(stream["dir"], ignore_errors=True)++ with self.stream_mu:++ if self.stream is stream:++ self.stream = None++ # The core ends the stream with the same id (a newer one that replaced it carries its own).++ self.emit_event("screen_stream_ended", sid=stream["sid"], reason=stream.get("reason") or reason, frames=n)++++ def _stream_frame(self, tool, path):++ """One JPEG frame of the screen at path, at most STREAM_WIDTH wide when Pillow can resize it."""++ if self._have_pillow():++ fd, png = tempfile.mkstemp(prefix="openvibe-screen-", suffix=".png")++ os.close(fd)++ try:++ self._capture(tool[1](png))++ from PIL import Image++ with Image.open(png) as im:++ im = im.convert("RGB")++ if im.width > STREAM_WIDTH:++ im = im.resize((STREAM_WIDTH, max(1, round(im.height * STREAM_WIDTH / im.width))))++ im.save(path + ".part", "JPEG", quality=STREAM_QUALITY)++ os.replace(path + ".part", path)++ finally:++ if os.path.exists(png):++ os.unlink(png)++ return++ self._capture(self._native_jpeg(tool, path + ".part.jpg"))++ os.replace(path + ".part.jpg", path)++++ def _capture(self, argv):++ proc = self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,++ start_new_session=True)++ try:++ _, err = proc.communicate(timeout=10)++ except subprocess.TimeoutExpired:++ self._kill(proc)++ raise Fault("hardware", "the screen capture did not finish in 10 s")++ if proc.returncode != 0:++ raise Fault("hardware", (err or b"").decode("utf-8", "replace")[:200] or "the capture tool failed")++++ def _notify_stream(self, seconds):++ """Say on the desktop that the screen is live, and how to end it (config `notify`). Best effort."""++ if not self.notify:++ return++ title = "Your screen is live on OpenVibe"++ body = "Shown to your own account for %d min. Run `openvibe-node stop` to end it now." % max(1, seconds // 60)++ argv = None++ if self.platform.startswith("linux") and (self.env.get("DISPLAY") or self.env.get("WAYLAND_DISPLAY")) and self.which("notify-send"):++ argv = ["notify-send", "--app-name=OpenVibe Node", "--", title, body]++ elif self.platform == "darwin" and self.which("osascript"):++ quote = lambda s: '"%s"' % s.replace("\\", "\\\\").replace('"', '\\"')++ argv = ["osascript", "-e", "display notification %s with title %s" % (quote(body), quote(title))]++ if argv is None:++ return++ try:++ self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,++ start_new_session=True)++ except OSError:++ pass++@staticmethoddef _encode(path, max_width):try:@@@ -418,6 +418,81 @@ class ComputerTest(unittest.TestCase):self.assertEqual(base64.b64decode(d["content_b64"])[:2], b"\xff\xd8")++ # ---- the live screen -----------------------------------------------------------------------------------------++ def boot_stream(self, level="observe", **config):++ """A plugin whose screen tool copies one JPEG, so frames come without a display."""++ self.boot(level, notify=False, **config)++ src = os.path.join(self.dir, "screen.jpg")++ with open(src, "wb") as f:++ f.write(b"\xff\xd8\xff\xe0" + b"\x00" * 64 + b"\xff\xd9")++ self.plugin._screen_tool = lambda: ("scrot", lambda out: [sys.executable, "-c", "import shutil,sys; shutil.copy(sys.argv[1], sys.argv[2])", src, out])++ self.plugin._have_pillow = lambda: False++ self.plugin._native_jpeg = lambda tool, path: [sys.executable, "-c", "import shutil,sys; shutil.copy(sys.argv[1], sys.argv[2])", src, path]++++ def test_screen_stream_emits_frames_at_every_level_and_stops(self):++ self.boot_stream("observe")++ r = reply(self.out, self.cmd("screen_stream", {"action": "start", "seconds": 60, "fps": 5}))++ self.assertEqual(r["op"], "ack")++ d = r["data"]++ self.assertEqual((d["streaming"], d["seconds"], d["fps"], d["tool"]), (True, 60, 5, "scrot"))++ frame = self.out.wait_for(lambda m: m.get("op") == "video" and m.get("stream") == "screen")++ self.assertEqual(frame["format"], "jpeg")++ self.assertTrue(frame["path"].startswith(tempfile.gettempdir()))++ self.out.wait_for(lambda m: m.get("op") == "video" and m.get("path", "").endswith("frame-1.jpg"))++ self.assertEqual(self.plugin.poll()["sensors"]["computer_screen_stream"], "live")++ stop = reply(self.out, self.cmd("screen_stream", {"action": "stop"}))++ self.assertEqual(stop["data"], {"streaming": False, "stopped": True})++ ended = self.out.wait_for(lambda m: m.get("op") == "event" and m.get("name") == "screen_stream_ended")++ self.assertEqual((ended["sid"], ended["reason"]), (d["sid"], "stopped by its owner"))++ self.assertFalse(os.path.exists(os.path.dirname(frame["path"])), "the frames' folder is removed")++ self.assertEqual(self.plugin.poll()["sensors"]["computer_screen_stream"], "off")++++ def test_screen_stream_ends_on_the_kill_switch_and_is_refused_while_latched(self):++ self.boot_stream("trusted")++ first = reply(self.out, self.cmd("screen_stream", {"action": "start", "seconds": 60}))["data"]++ self.rt.dispatch({"op": "estop"})++ ended = self.out.wait_for(lambda m: m.get("op") == "event" and m.get("name") == "screen_stream_ended")++ self.assertEqual((ended["sid"], ended["reason"]), (first["sid"], "stopped on the machine"))++ r = reply(self.out, self.cmd("screen_stream", {"action": "start"}))++ self.assertEqual((r["op"], r["fault_code"]), ("nack", "local_stop"))++++ def test_screen_stream_replaces_and_ends_on_its_own(self):++ self.boot_stream()++ a = reply(self.out, self.cmd("screen_stream", {"action": "start", "seconds": 60}))["data"]++ b = reply(self.out, self.cmd("screen_stream", {"action": "start", "seconds": 30}))["data"]++ self.assertNotEqual(a["sid"], b["sid"])++ ended = self.out.wait_for(lambda m: m.get("op") == "event" and m.get("name") == "screen_stream_ended")++ self.assertEqual((ended["sid"], ended["reason"]), (a["sid"], "replaced"))++ # Time up: a clock moved past the end stops the frames.++ self.plugin.stream["until"] = 0++ late = self.out.wait_for(lambda m: m.get("op") == "event" and m.get("name") == "screen_stream_ended" and m.get("sid") == b["sid"])++ self.assertEqual(late["reason"], "time is up")++++ def test_screen_stream_refusals(self):++ self.boot("observe", allow_screen_stream=False)++ self.plugin._screen_tool = lambda: ("scrot", lambda out: ["true"])++ r = reply(self.out, self.cmd("screen_stream", {"action": "start"}))++ self.assertEqual(r["fault_code"], "unsupported")++ self.boot("observe")++ r = reply(self.out, self.cmd("screen_stream", {"action": "start"}))++ self.assertEqual((r["fault_code"], r["message"]), ("unsupported", "no screen capture tool on this machine"))++ self.plugin._screen_tool = lambda: ("gnome-screenshot", lambda out: ["true"])++ self.plugin._have_pillow = lambda: False++ r = reply(self.out, self.cmd("screen_stream", {"action": "start"}))++ self.assertIn("install Pillow", r["message"])++ r = reply(self.out, self.cmd("screen_stream", {"action": "watch"}))++ self.assertEqual(r["fault_code"], "bad_value")++++ def test_screen_stream_capability_and_native_jpeg_tools(self):++ c = Computer(env={}, platform="linux")++ self.assertEqual(c._native_jpeg(("grim", None), "/t/f.jpg"), ["grim", "-t", "jpeg", "-q", "70", "/t/f.jpg"])++ self.assertEqual(c._native_jpeg(("screencapture", None), "/t/f.jpg"), ["screencapture", "-x", "-t", "jpg", "/t/f.jpg"])++ self.assertIsNone(c._native_jpeg(("gnome-screenshot", None), "/t/f.jpg"))++ which = lambda name: "/usr/bin/" + name if name == "grim" else None++ w = Computer(which=which, env={"WAYLAND_DISPLAY": "wayland-0"}, platform="linux")++ self.assertTrue(w.describe({"level": "observe"})["screen"]["stream"])++ self.assertFalse(w.describe({"level": "observe", "allow_screen_stream": False})["screen"]["stream"])++# ---- input ---------------------------------------------------------------------------------------------------def boot_input(self, level, platform="linux", tool="xdotool", size=(2560, 1440)):self.argvs = []
@@ -45,6 +45,8 @@ on a community profile runs the same check and warns when it fails. Install a co
@@ -45,6 +45,8 @@ on a community profile runs the same check and warns when it fails. Install a co
**Your computer for your agents:** `--computer observe|ask|trusted|full` adds the `computer` plugin (terminal, files,
**Your computer for your agents:** `--computer observe|ask|trusted|full` adds the `computer` plugin (terminal, files,
screen, mouse and keyboard, and a browser of its own for OpenVibe.Actor), running as your own account, at the level you set on the machine; `ask` waits for
screen, mouse and keyboard, and a browser of its own for OpenVibe.Actor), running as your own account, at the level you set on the machine; `ask` waits for
your answer in the tray, a notification's Approve button or `openvibe-node approve <id>` ([docs/computer.md](docs/computer.md)).
your answer in the tray, a notification's Approve button or `openvibe-node approve <id>` ([docs/computer.md](docs/computer.md)).
+With it on, you can also watch the machine's screen live on openvibe.bot. Only you see it, it runs for up to 30
+minutes, and `openvibe-node stop` ends it at once ([the live screen](docs/computer.md#the-live-screen-screen_stream-node-v0120)).
**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: the
**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: the
job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding
job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding
@@ -699,6 +699,14 @@ func cmdStatus(g *globals, asJSON bool, stdout io.Writer) error {
@@ -699,6 +699,14 @@ func cmdStatus(g *globals, asJSON bool, stdout io.Writer) error {
fmt.Fprintf(stdout, " (%s)", s.Video.LastError)
fmt.Fprintf(stdout, " (%s)", s.Video.LastError)
}
}
fmt.Fprintln(stdout)
fmt.Fprintln(stdout)
+ if s.Screen.State != "" && s.Screen.State != "off" {
+ // Shown only while it runs: the owner sees at a glance that their screen is live.
+ fmt.Fprintf(stdout, "Screen: %s to your OpenVibe account until %s, %d frames", s.Screen.State, s.Screen.Until, s.Screen.Frames)
+ if s.Screen.LastError != "" && s.Screen.State != "live" {
+ fmt.Fprintf(stdout, " (%s)", s.Screen.LastError)
+ }
+ fmt.Fprintln(stdout, "; `openvibe-node stop` ends it")
+ }
fmt.Fprintln(stdout, "Plugins:")
fmt.Fprintln(stdout, "Plugins:")
for _, p := range s.Plugins {
for _, p := range s.Plugins {
fmt.Fprintf(stdout, " %-15s %-9s %s %s", p.Name, p.State, p.Driver, p.Version)
fmt.Fprintf(stdout, " %-15s %-9s %s %s", p.Name, p.State, p.Driver, p.Version)
@@ -90,6 +90,24 @@ A program runs in its own process group with the Node account's rights, at most
@@ -90,6 +90,24 @@ A program runs in its own process group with the Node account's rights, at most
from `screencapture` (macOS), `grim` (Wayland) or `import`, `scrot` or `gnome-screenshot` (X11), so the Node must run in
from `screencapture` (macOS), `grim` (Wayland) or `import`, `scrot` or `gnome-screenshot` (X11), so the Node must run in
your graphical session for `screen`; without one the plugin simply does not offer it.
your graphical session for `screen`; without one the plugin simply does not offer it.
+### The live screen (`screen_stream`, Node v0.12.0)
+
+You can watch your machine's screen live on openvibe.bot (**Your machines** → **Show screen**), for 30 seconds to
+30 minutes at a time:
+
+- **Only you see it.** Bot gives your machine's start command the WHIP address and key of its own private
+ OpenRestream stream (no recording, never on Live). No operator or agent supplies them, and agents cannot start it.
+- **How it works.** The plugin captures a JPEG frame with the same tools as `screen`, 1 to 5 times a second (2 by
+ default), at most 1600 px wide when Pillow is installed. The core encodes the frames (ffmpeg, else its built-in
+ encoder) and publishes them on a WebRTC session of their own, separate from a robot's camera.
+- **You are told.** The desktop says when it starts ("Your screen is live on OpenVibe"), and `openvibe-node status`
+ shows `Screen: live … until …` while it runs.
+- **It ends** when its time is up, when you press Stop, when capturing fails five times in a row, and at once on
+ `openvibe-node stop` or an e-stop. While a stop is latched, a new start is refused.
+- **Levels.** It runs at every level, like a screenshot. To never offer it, set `allow_screen_stream: false`.
+- **Without Pillow** the capture tool must write JPEG itself: `screencapture`, `grim`, `import` and `scrot` do;
+ `gnome-screenshot` does not.
+
**Mouse and keyboard** (`input`) is an acting command like running a program: refused at `observe`, approved one by one
**Mouse and keyboard** (`input`) is an acting command like running a program: refused at `observe`, approved one by one
at `ask` (the approval says exactly what it would do: "click at 640, 360 of a 1280×720 picture of the screen",
at `ask` (the approval says exactly what it would do: "click at 640, 360 of a 1280×720 picture of the screen",
"type 12 characters: 'hello there'", "press ctrl+s"). Coordinates are in the space of the screen picture the agent last
"type 12 characters: 'hello there'", "press ctrl+s"). Coordinates are in the space of the screen picture the agent last
@@ -121,12 +139,14 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
@@ -121,12 +139,14 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
| `probe` | `true` | `false`: no probes (OpenVibe.Watch's `node` source) |
| `probe` | `true` | `false`: no probes (OpenVibe.Watch's `node` source) |
| `allow_input` | `true` | `false`: never use the mouse or keyboard (no `input` capability) |
| `allow_input` | `true` | `false`: never use the mouse or keyboard (no `input` capability) |
| `allow_browser` | `true` | `false`: no browser (no `browser` capability) |
| `allow_browser` | `true` | `false`: no browser (no `browser` capability) |
+| `allow_screen_stream` | `true` | `false`: never show the screen live (`screen.stream` is false) |
| `browser_path` | found on PATH | the browser to drive (Chromium, Chrome, Edge or Brave) |
| `browser_path` | found on PATH | the browser to drive (Chromium, Chrome, Edge or Brave) |
| `browser_profile` | `~/.local/share/openvibe-node/browser` | its profile folder: the agent's own, unless you choose another |
| `browser_profile` | `~/.local/share/openvibe-node/browser` | its profile folder: the agent's own, unless you choose another |
| `browser_headless` | `true` | `false`: show its window |
| `browser_headless` | `true` | `false`: show its window |
| `browser_idle_s` | 600 | close it after this long without a command |
| `browser_idle_s` | 600 | close it after this long without a command |
`describe` reports `terminal`, `files`, `screen`, `input` and `browser` with the level and folders, so Bot shows what the machine allows;
`describe` reports `terminal`, `files`, `screen`, `input` and `browser` with the level and folders, so Bot shows what the machine allows;
-telemetry carries the sensors `computer_level` (observe while a stop is latched), `computer_waiting` and
-`computer_running`.
+telemetry carries the sensors `computer_level` (observe while a stop is latched), `computer_waiting`,
+`computer_running` and `computer_screen_stream` (`live` or `off`). `screen.stream` says whether the live screen is
+offered.
Every request is logged by the Node (`journalctl -u openvibe-node`), with what it would do, approved or denied.
Every request is logged by the Node (`journalctl -u openvibe-node`), with what it would do, approved or denied.
@@ -201,6 +201,7 @@ at most every 500 ms (≤ 2 Hz, Bot's cap). There is no separate `event` message
@@ -201,6 +201,7 @@ at most every 500 ms (≤ 2 Hz, Bot's cap). There is no separate `event` message
| `exec` | `{"argv": ["prog", "arg"…], "cwd"?, "timeout_s"?, "stdin"?}`: run a program on the machine ([computer.md](computer.md)) | computer |
| `exec` | `{"argv": ["prog", "arg"…], "cwd"?, "timeout_s"?, "stdin"?}`: run a program on the machine ([computer.md](computer.md)) | computer |
| `fs` | `{"op": "list"\|"stat"\|"read"\|"write"\|"mkdir", "path", …}`: a file or folder on the machine | computer |
| `fs` | `{"op": "list"\|"stat"\|"read"\|"write"\|"mkdir", "path", …}`: a file or folder on the machine | computer |
| `screen` | `{"max_width"?}`: capture the machine's screen | computer |
| `screen` | `{"max_width"?}`: capture the machine's screen | computer |
+| `screen_stream` | `{"action": "start", "whip_url", "publish_key", "seconds"?, "fps"?}` or `{"action": "stop"}`: show the screen live on the machine's private stream (Bot fills in the URL and key) | computer |
| `probe` | `{"target": "http(s)://…" \| "tcp://host:port", "timeout_s"?}`: a read-only check of the machine's own network | computer |
| `probe` | `{"target": "http(s)://…" \| "tcp://host:port", "timeout_s"?}`: a read-only check of the machine's own network | computer |
Sign conventions: `throttle`/`x` positive = forward; `steer` positive = turn right; `y` positive = strafe left;
Sign conventions: `throttle`/`x` positive = forward; `steer` positive = turn right; `y` positive = strafe left;
@@ -26,6 +26,12 @@ import (
@@ -26,6 +26,12 @@ import (
// startComputer runs a Node with only the real Python computer plugin at the given control level, its roots the one
// startComputer runs a Node with only the real Python computer plugin at the given control level, its roots the one
// folder it returns, against the fake Bot.
// folder it returns, against the fake Bot.
func startComputer(t *testing.T, level string) (*env, string) {
func startComputer(t *testing.T, level string) (*env, string) {
+ t.Helper()
+ return startComputerWith(t, level, nil)
+}
+
+// startComputerWith is startComputer with tweak applied to the plugin's config and the Node's options before it starts.
+func startComputerWith(t *testing.T, level string, tweak func(*config.PluginConfig, *Options)) (*env, string) {
t.Helper()
t.Helper()
if runtime.GOOS == "windows" {
if runtime.GOOS == "windows" {
t.Skip("end-to-end tests run on unix")
t.Skip("end-to-end tests run on unix")
@@ -66,8 +72,12 @@ func startComputer(t *testing.T, level string) (*env, string) {
@@ -66,8 +72,12 @@ func startComputer(t *testing.T, level string) (*env, string) {
cfg.Plugins = []config.PluginConfig{{Name: "computer", Config: map[string]any{"level": level, "roots": []string{work}, "timeout_s": 20},
cfg.Plugins = []config.PluginConfig{{Name: "computer", Config: map[string]any{"level": level, "roots": []string{work}, "timeout_s": 20},
Env: map[string]string{"PYTHONPATH": filepath.Join(root, "plugins/sdk") + string(os.PathListSeparator) + filepath.Join(root, "plugins/computer")}}}
Env: map[string]string{"PYTHONPATH": filepath.Join(root, "plugins/sdk") + string(os.PathListSeparator) + filepath.Join(root, "plugins/computer")}}}
cfg.Video = config.VideoConfig{Source: "off"}
cfg.Video = config.VideoConfig{Source: "off"}
- n, err := New(Options{Config: cfg, Paths: paths, Creds: creds, Version: "test", HeartbeatInterval: 100 * time.Millisecond,
- LinkBackoffMin: 50 * time.Millisecond, LatchPoll: 50 * time.Millisecond, Log: testLog()})
+ opt := Options{Config: cfg, Paths: paths, Creds: creds, Version: "test", HeartbeatInterval: 100 * time.Millisecond,
+ LinkBackoffMin: 50 * time.Millisecond, LatchPoll: 50 * time.Millisecond, Log: testLog()}
+ if tweak != nil {
+ tweak(&opt.Config.Plugins[0], &opt)
+ }
+ n, err := New(opt)
if err != nil {
if err != nil {
t.Fatal(err)
t.Fatal(err)
}
}
@@ -66,6 +66,8 @@ type Node struct {
@@ -66,6 +66,8 @@ type Node struct {
mgr *plugins.Manager
mgr *plugins.Manager
link *link.Link
link *link.Link
pub *video.Publisher
pub *video.Publisher
+ // screen is the owner's live view of the machine's screen (screen.go), apart from a robot's camera.
+ screen screenState
ctx context.Context
ctx context.Context
@@ -496,6 +498,10 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, r
@@ -496,6 +498,10 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, r
nack(protocol.FaultBadValue, "value must be a JSON object")
nack(protocol.FaultBadValue, "value must be a JSON object")
return
return
}
}
+ if c.Kind == protocol.KindScreenStream {
+ n.screenCommand(c, entry, received, nack)
+ return
+ }
if protocol.ComputerActs(c.Kind, c.Value) {
if protocol.ComputerActs(c.Kind, c.Value) {
st := n.latch.State()
st := n.latch.State()
if st.Local {
if st.Local {
@@ -670,12 +676,20 @@ func (n *Node) output(o plugins.Output) {
@@ -670,12 +676,20 @@ func (n *Node) output(o plugins.Output) {
fields = nil
fields = nil
}
}
n.log.Info("plugin event", "plugin", o.Plugin, "event", name)
n.log.Info("plugin event", "plugin", o.Plugin, "event", name)
+ if name == "screen_stream_ended" {
+ sid, _ := o.Data["sid"].(string)
+ n.screenEnded(o.Plugin, sid)
+ }
n.trackApproval(o.Plugin, name, o.Data, o.At)
n.trackApproval(o.Plugin, name, o.Data, o.At)
n.mu.Lock()
n.mu.Lock()
n.events = append(n.events, protocol.Event{Name: name, Driver: o.Plugin, TS: o.At.UnixMilli(), Fields: fields})
n.events = append(n.events, protocol.Event{Name: name, Driver: o.Plugin, TS: o.At.UnixMilli(), Fields: fields})
n.mu.Unlock()
n.mu.Unlock()
n.flushTelemetry(true)
n.flushTelemetry(true)
case plugins.OutVideo:
case plugins.OutVideo:
+ if stream, _ := o.Data["stream"].(string); stream == "screen" {
+ n.screenFrame(o)
+ return
+ }
n.mu.Lock()
n.mu.Lock()
j, from := n.jpeg, n.jpegFrom
j, from := n.jpeg, n.jpegFrom
n.mu.Unlock()
n.mu.Unlock()
@@ -875,6 +889,7 @@ type Status struct {
@@ -875,6 +889,7 @@ type Status struct {
Limits safety.Limits `json:"limits"`
Limits safety.Limits `json:"limits"`
Plugins []PluginStatus `json:"plugins"`
Plugins []PluginStatus `json:"plugins"`
Video VideoStatus `json:"video"`
Video VideoStatus `json:"video"`
+ Screen ScreenStatus `json:"screen"`
ConfigDir string `json:"config_dir"`
ConfigDir string `json:"config_dir"`
StateDir string `json:"state_dir"`
StateDir string `json:"state_dir"`
Telemetry protocol.Telemetry `json:"telemetry"`
Telemetry protocol.Telemetry `json:"telemetry"`
@@ -933,6 +948,7 @@ func (n *Node) Status() Status {
@@ -933,6 +948,7 @@ func (n *Node) Status() Status {
if n.worker != nil {
if n.worker != nil {
s.Jobs = n.worker.Jobs()
s.Jobs = n.worker.Jobs()
}
}
+ s.Screen = n.screenStatus()
s.Video.State = video.StateOff
s.Video.State = video.StateOff
if n.pub != nil {
if n.pub != nil {
s.Video.State, s.Video.Frames, s.Video.LastError = n.pub.State(), n.pub.Frames(), n.pub.LastError()
s.Video.State, s.Video.Frames, s.Video.LastError = n.pub.State(), n.pub.Frames(), n.pub.LastError()
new file mode 100644
new file mode 100644
@@ -0,0 +1,264 @@
@@ -0,0 +1,264 @@
+package node
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net"
+ "net/url"
+ "sync"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/credentials"
+ "github.com/OpenVibers/OpenVibe.Node/internal/plugins"
+ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
+ "github.com/OpenVibers/OpenVibe.Node/internal/safety"
+ "github.com/OpenVibers/OpenVibe.Node/internal/video"
+)
+
+// The screen stream (screen_stream, contracts 0.182.0): the machine's screen shown live to its owner. Bot mints the
+// WHIP URL and publish key of the machine's own private OpenRestream stream and sends them in the start command; the
+// computer plugin captures the frames (the same tools as `screen`, at the owner's level) and emits them as video
+// with stream "screen"; the core encodes and publishes them on a second WHIP session, apart from a robot's camera.
+//
+// A stream ends when its time is up, on stop, when the plugin says it ended, and at once when the local kill switch
+// or an e-stop latches; a start is refused while either is latched. One stream at a time: a new start replaces it.
+const (
+ ScreenDefaultSeconds = 600
+ ScreenMinSeconds = 30
+ ScreenMaxSeconds = 1800
+ ScreenDefaultFPS = 2
+ ScreenMaxFPS = 5
+ // The built-in encoder's picture size when ffmpeg is missing (ffmpeg keeps the frames' own size).
+ screenFallbackW, screenFallbackH = 960, 544
+)
+
+type screenStream struct {
+ pub *video.Publisher
+ src *video.JPEGSource
+ from string // the plugin whose frames feed it
+ sid string // the plugin's id for it: its "ended" event names it
+ until time.Time
+ cancel context.CancelFunc
+ done chan struct{}
+}
+
+type screenState struct {
+ mu sync.Mutex
+ cur *screenStream
+}
+
+// ScreenStatus is the screen stream in `openvibe-node status`.
+type ScreenStatus struct {
+ State string `json:"state"`
+ Until string `json:"until,omitempty"`
+ Frames int64 `json:"frames,omitempty"`
+ LastError string `json:"last_error,omitempty"`
+}
+
+type screenValue struct {
+ Action string `json:"action"`
+ WHIPURL string `json:"whip_url"`
+ PublishKey string `json:"publish_key"`
+ Seconds *int `json:"seconds"`
+ FPS *int `json:"fps"`
+}
+
+// screenCommand answers screen_stream: the plugin is asked first (it applies the owner's level and has the capture
+// tool), and only once it said yes does the core publish.
+func (n *Node) screenCommand(c protocol.Command, entry *safety.Entry, received time.Time, nack func(code, msg string)) {
+ var v screenValue
+ if err := json.Unmarshal(c.Value, &v); err != nil {
+ nack(protocol.FaultBadValue, "value must be {action, …}")
+ return
+ }
+ p := n.mgr.Route(protocol.KindScreenStream, c.Target, "")
+ switch v.Action {
+ case "stop":
+ stopped := n.stopScreen("stopped")
+ if p != nil {
+ // Best effort: the plugin stops capturing (its answer does not change the core's).
+ wait := p.Begin(n.ctx, c.ID+":stop", protocol.KindScreenStream, json.RawMessage(`{"action":"stop"}`), 5000)
+ go wait()
+ }
+ data, _ := json.Marshal(map[string]any{"streaming": false, "stopped": stopped})
+ r := protocol.AckData{ID: c.ID, LatencyMS: time.Since(received).Milliseconds(), Data: data}
+ n.dedup.Complete(entry, r)
+ n.send(r)
+ return
+ case "start":
+ default:
+ nack(protocol.FaultBadValue, "action is start or stop")
+ return
+ }
+ st := n.latch.State()
+ if st.Local {
+ nack(protocol.FaultLocalStop, "stopped on the machine with `openvibe-node stop`")
+ return
+ }
+ if st.Remote {
+ nack(protocol.FaultEstopped, st.RemoteReason)
+ return
+ }
+ if err := n.checkScreenURL(v.WHIPURL); err != nil {
+ nack(protocol.FaultBadValue, err.Error())
+ return
+ }
+ if v.PublishKey == "" || len(v.PublishKey) > 256 {
+ nack(protocol.FaultBadValue, "publish_key is required")
+ return
+ }
+ seconds, fps := ScreenDefaultSeconds, ScreenDefaultFPS
+ if v.Seconds != nil {
+ seconds = min(max(*v.Seconds, ScreenMinSeconds), ScreenMaxSeconds)
+ }
+ if v.FPS != nil {
+ fps = min(max(*v.FPS, 1), ScreenMaxFPS)
+ }
+ if p == nil {
+ nack(protocol.FaultUnsupported, "no driver on this device captures the screen (the computer plugin is off)")
+ return
+ }
+ // The plugin never sees the URL or the key.
+ toPlugin, _ := json.Marshal(map[string]any{"action": "start", "seconds": seconds, "fps": fps})
+ wait := p.Begin(n.ctx, c.ID, protocol.KindScreenStream, toPlugin, 15000)
+ key := credentials.NewSecret(v.PublishKey)
+ go func() {
+ r := wait()
+ var msg protocol.Message
+ if r.OK {
+ var got struct {
+ SID string `json:"sid"`
+ }
+ _ = json.Unmarshal(r.Data, &got)
+ until := n.startScreen(v.WHIPURL, key, seconds, fps, p.Name(), got.SID)
+ data, _ := json.Marshal(map[string]any{"streaming": true, "seconds": seconds, "fps": fps, "until_ms": until.UnixMilli()})
+ msg = protocol.AckData{ID: c.ID, LatencyMS: time.Since(received).Milliseconds(), Data: data}
+ } else {
+ msg = protocol.Nack{ID: c.ID, FaultCode: r.FaultCode, Message: r.Message}
+ }
+ n.dedup.Complete(entry, msg)
+ n.send(msg)
+ }()
+}
+
+// checkScreenURL accepts an https WHIP endpoint with no credentials in it (http only to loopback, for tests).
+func (n *Node) checkScreenURL(raw string) error {
+ u, err := url.Parse(raw)
+ if err != nil || u.Host == "" || u.User != nil || len(raw) > 2048 {
+ return fmt.Errorf("whip_url must be an https URL")
+ }
+ switch u.Scheme {
+ case "https":
+ return nil
+ case "http":
+ if ip := net.ParseIP(u.Hostname()); n.opt.VideoLoopback && ip != nil && ip.IsLoopback() {
+ return nil
+ }
+ }
+ return fmt.Errorf("whip_url must be an https URL")
+}
+
+// startScreen publishes the plugin's screen frames until the time is up (replacing a stream that runs) → the end.
+func (n *Node) startScreen(whip string, key credentials.Secret, seconds, fps int, from, sid string) time.Time {
+ n.stopScreen("replaced")
+ ctx, cancel := context.WithCancel(n.ctx)
+ until := time.Now().Add(time.Duration(seconds) * time.Second)
+ src := &video.JPEGSource{FFmpeg: n.opt.Config.Video.FFmpeg, FPS: fps, W: screenFallbackW, H: screenFallbackH, Log: n.log}
+ pub := video.NewPublisher(video.Options{WHIPURL: whip, PublishKey: key, ICEServers: n.screenICE(), Log: n.log, IncludeLoopback: n.opt.VideoLoopback})
+ s := &screenStream{pub: pub, src: src, from: from, sid: sid, until: until, cancel: cancel, done: make(chan struct{})}
+ n.screen.mu.Lock()
+ n.screen.cur = s
+ n.screen.mu.Unlock()
+ n.log.Info("screen stream started", "seconds", seconds, "fps", fps)
+ go func() {
+ defer close(s.done)
+ pub.Run(ctx, src)
+ }()
+ go func() {
+ t := time.NewTicker(250 * time.Millisecond)
+ defer t.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case now := <-t.C:
+ st := n.latch.State()
+ switch {
+ case st.Local || st.Remote:
+ n.endScreen(s, "stopped on the machine")
+ return
+ case now.After(until.Add(3 * time.Second)):
+ n.endScreen(s, "time is up")
+ return
+ }
+ }
+ }
+ }()
+ return until
+}
+
+func (n *Node) screenICE() []protocol.ICEServer {
+ if n.opt.Creds == nil {
+ return nil
+ }
+ return n.opt.Creds.ICEServers
+}
+
+// endScreen ends s if it is still the running stream.
+func (n *Node) endScreen(s *screenStream, why string) {
+ n.screen.mu.Lock()
+ if n.screen.cur != s {
+ n.screen.mu.Unlock()
+ return
+ }
+ n.screen.cur = nil
+ n.screen.mu.Unlock()
+ s.cancel()
+ n.log.Info("screen stream ended", "why", why)
+}
+
+// stopScreen ends the running stream, if any → whether one ran.
+func (n *Node) stopScreen(why string) bool {
+ n.screen.mu.Lock()
+ s := n.screen.cur
+ n.screen.mu.Unlock()
+ if s == nil {
+ return false
+ }
+ n.endScreen(s, why)
+ return true
+}
+
+// screenEnded ends the stream the plugin says it ended (by its id), never a newer one.
+func (n *Node) screenEnded(plugin, sid string) {
+ n.screen.mu.Lock()
+ s := n.screen.cur
+ n.screen.mu.Unlock()
+ if s != nil && s.from == plugin && s.sid != "" && s.sid == sid {
+ n.endScreen(s, "the plugin ended it")
+ }
+}
+
+// screenFrame passes a plugin's screen frame to the running stream (frames from any other plugin are dropped).
+func (n *Node) screenFrame(o plugins.Output) {
+ n.screen.mu.Lock()
+ s := n.screen.cur
+ n.screen.mu.Unlock()
+ if s == nil || s.from != o.Plugin {
+ return
+ }
+ if p, ok := o.Data["path"].(string); ok {
+ s.src.Push(p)
+ }
+}
+
+func (n *Node) screenStatus() ScreenStatus {
+ n.screen.mu.Lock()
+ s := n.screen.cur
+ n.screen.mu.Unlock()
+ if s == nil {
+ return ScreenStatus{State: video.StateOff}
+ }
+ return ScreenStatus{State: s.pub.State(), Until: s.until.UTC().Format(time.RFC3339), Frames: s.pub.Frames(), LastError: s.pub.LastError()}
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -0,0 +1,140 @@
+//go:build !windows
+
+package node
+
+import (
+ "image"
+ "image/color"
+ "image/jpeg"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/config"
+ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
+ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
+ "github.com/OpenVibers/OpenVibe.Node/internal/video"
+)
+
+// startScreenNode runs the real computer plugin with a fake `grim` (a Wayland session) that copies a real JPEG to the
+// file it is given, so the screen stream has frames without a display; video may use loopback.
+func startScreenNode(t *testing.T, level string) *env {
+ t.Helper()
+ bin := t.TempDir()
+ pic := filepath.Join(bin, "screen.jpg")
+ img := image.NewRGBA(image.Rect(0, 0, 320, 240))
+ for y := 0; y < 240; y++ {
+ for x := 0; x < 320; x++ {
+ img.Set(x, y, color.RGBA{uint8(x), uint8(y), 160, 255})
+ }
+ }
+ f, err := os.Create(pic)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := jpeg.Encode(f, img, nil); err != nil {
+ t.Fatal(err)
+ }
+ f.Close()
+ // grim [-t jpeg -q N] <out>: the output is the last argument.
+ script := "#!/bin/sh\nfor last; do :; done\ncp \"" + pic + "\" \"$last\"\n"
+ if err := os.WriteFile(filepath.Join(bin, "grim"), []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ e, _ := startComputerWith(t, level, func(pc *config.PluginConfig, o *Options) {
+ pc.Env["PATH"] = bin + string(os.PathListSeparator) + os.Getenv("PATH")
+ pc.Env["WAYLAND_DISPLAY"] = "wayland-test"
+ pc.Config["notify"] = false
+ o.VideoLoopback = true
+ })
+ return e
+}
+
+func screenWHIP(t *testing.T) (*video.WHIPReceiver, string) {
+ t.Helper()
+ w := &video.WHIPReceiver{PublishKey: "ork_screen_test_key"}
+ srv := httptest.NewServer(w)
+ t.Cleanup(func() { w.Close(); srv.Close() })
+ return w, srv.URL + "/whip/ork_screen_test_key"
+}
+
+func TestScreenStreamPublishesTheScreenAndStops(t *testing.T) {
+ e := startScreenNode(t, "observe")
+ whip, url := screenWHIP(t)
+ r := e.computerCmd("s1", protocol.KindScreenStream, map[string]any{"action": "start", "whip_url": url, "publish_key": "ork_screen_test_key", "seconds": 60, "fps": 5}, 20*time.Second)
+ d := dataOf(t, r)
+ if d["streaming"] != true || d["seconds"] != float64(60) || d["fps"] != float64(5) {
+ t.Fatalf("%+v", d)
+ }
+ deadline := time.Now().Add(20 * time.Second)
+ for whip.Packets.Load() < 10 {
+ if time.Now().After(deadline) {
+ t.Fatalf("no screen video: %+v", e.node.Status().Screen)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ if st := e.node.Status().Screen; st.State != video.StateLive || st.Until == "" {
+ t.Fatalf("status %+v", st)
+ }
+ if whip.BadAuth.Load() != 0 {
+ t.Fatal("the publish key was not sent as the bearer")
+ }
+ stop := dataOf(t, e.computerCmd("s2", protocol.KindScreenStream, map[string]any{"action": "stop"}, 10*time.Second))
+ if stop["stopped"] != true {
+ t.Fatalf("%+v", stop)
+ }
+ deadline = time.Now().Add(5 * time.Second)
+ for e.node.Status().Screen.State != video.StateOff || whip.Deletes.Load() == 0 {
+ if time.Now().After(deadline) {
+ t.Fatalf("still streaming after stop: %+v (deletes %d)", e.node.Status().Screen, whip.Deletes.Load())
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
+func TestScreenStreamEndsOnTheKillSwitchAndIsRefusedWhileLatched(t *testing.T) {
+ e := startScreenNode(t, "trusted")
+ whip, url := screenWHIP(t)
+ start := map[string]any{"action": "start", "whip_url": url, "publish_key": "ork_screen_test_key", "fps": 5}
+ dataOf(t, e.computerCmd("k1", protocol.KindScreenStream, start, 20*time.Second))
+ deadline := time.Now().Add(20 * time.Second)
+ for whip.Packets.Load() < 5 {
+ if time.Now().After(deadline) {
+ t.Fatalf("no screen video: %+v", e.node.Status().Screen)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ if _, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStop}, 2*time.Second); err != nil {
+ t.Fatal(err)
+ }
+ deadline = time.Now().Add(3 * time.Second)
+ for e.node.Status().Screen.State != video.StateOff {
+ if time.Now().After(deadline) {
+ t.Fatalf("the kill switch did not end the stream: %+v", e.node.Status().Screen)
+ }
+ time.Sleep(25 * time.Millisecond)
+ }
+ if c := nackCode(e.computerCmd("k2", protocol.KindScreenStream, start, 10*time.Second)); c != protocol.FaultLocalStop {
+ t.Fatalf("start while latched: %q", c)
+ }
+}
+
+func TestScreenStreamRefusesBadTargets(t *testing.T) {
+ e := startScreenNode(t, "observe")
+ for i, v := range []map[string]any{
+ {"action": "start", "whip_url": "http://203.0.113.5/whip/k", "publish_key": "k"},
+ {"action": "start", "whip_url": "https://user:[email protected]/whip/k", "publish_key": "k"},
+ {"action": "start", "whip_url": "https://openre.stream/whip/k"},
+ {"action": "watch"},
+ } {
+ r := e.computerCmd("b"+string(rune('0'+i)), protocol.KindScreenStream, v, 10*time.Second)
+ if c := nackCode(r); c != protocol.FaultBadValue {
+ t.Fatalf("%v: %q", v, c)
+ }
+ }
+ if st := e.node.Status().Screen.State; st != video.StateOff {
+ t.Fatalf("%q", st)
+ }
+}
@@ -10,19 +10,20 @@ import (
@@ -10,19 +10,20 @@ import (
// capabilityFor maps a command kind to the describe capability key that declares it.
// capabilityFor maps a command kind to the describe capability key that declares it.
var capabilityFor = map[string]string{
var capabilityFor = map[string]string{
- protocol.KindDrive: "drive",
- protocol.KindPTZ: "ptz",
- protocol.KindActuator: "actuator",
- protocol.KindSay: "say",
- protocol.KindDisplay: "display",
- protocol.KindButton: "button",
- protocol.KindPoint: "point",
- protocol.KindExec: "terminal",
- protocol.KindFS: "files",
- protocol.KindScreen: "screen",
- protocol.KindProbe: "probe",
- protocol.KindInput: "input",
- protocol.KindBrowser: "browser",
+ protocol.KindDrive: "drive",
+ protocol.KindPTZ: "ptz",
+ protocol.KindActuator: "actuator",
+ protocol.KindSay: "say",
+ protocol.KindDisplay: "display",
+ protocol.KindButton: "button",
+ protocol.KindPoint: "point",
+ protocol.KindExec: "terminal",
+ protocol.KindFS: "files",
+ protocol.KindScreen: "screen",
+ protocol.KindScreenStream: "screen",
+ protocol.KindProbe: "probe",
+ protocol.KindInput: "input",
+ protocol.KindBrowser: "browser",
}
}
// Manager supervises every configured plugin.
// Manager supervises every configured plugin.
@@ -170,13 +170,18 @@ const (
@@ -170,13 +170,18 @@ const (
// KindBrowser drives a Chromium the agent uses, in a profile of its own (contracts 0.174.0): open, click, fill,
// KindBrowser drives a Chromium the agent uses, in a profile of its own (contracts 0.174.0): open, click, fill,
// press and back act (the owner's level decides, a latched stop refuses them); read, screenshot and close do not.
// press and back act (the owner's level decides, a latched stop refuses them); read, screenshot and close do not.
KindBrowser = "browser"
KindBrowser = "browser"
+ // KindScreenStream shows the machine's screen live to its owner (contracts 0.182.0): {"action": "start",
+ // "whip_url", "publish_key", "seconds"?, "fps"?} or {"action": "stop"}. Bot fills in the WHIP URL and key (the
+ // machine's private OpenRestream stream); no operator sends them. The computer plugin captures the frames, the
+ // core publishes them; a latched stop ends it and refuses a start.
+ KindScreenStream = "screen_stream"
)
)
// Kinds lists every command kind the Node understands.
// Kinds lists every command kind the Node understands.
-var Kinds = []string{KindDrive, KindActuator, KindPTZ, KindSay, KindDisplay, KindHalt, KindButton, KindPoint, KindExec, KindFS, KindScreen, KindProbe, KindInput, KindBrowser}
+var Kinds = []string{KindDrive, KindActuator, KindPTZ, KindSay, KindDisplay, KindHalt, KindButton, KindPoint, KindExec, KindFS, KindScreen, KindProbe, KindInput, KindBrowser, KindScreenStream}
// ComputerKinds move nothing: they carry long deadlines (DefaultComputerMS, at most MaxComputerMS) and no clamping.
// ComputerKinds move nothing: they carry long deadlines (DefaultComputerMS, at most MaxComputerMS) and no clamping.
-var ComputerKinds = map[string]bool{KindExec: true, KindFS: true, KindScreen: true, KindProbe: true, KindInput: true, KindBrowser: true}
+var ComputerKinds = map[string]bool{KindExec: true, KindFS: true, KindScreen: true, KindProbe: true, KindInput: true, KindBrowser: true, KindScreenStream: true}
const (
const (
// DefaultComputerMS is a computer command's reply window when the server sends no deadline; MaxComputerMS caps
// DefaultComputerMS is a computer command's reply window when the server sends no deadline; MaxComputerMS caps
@@ -21,6 +21,12 @@ Command kinds and their answers (the ack's ``data``):
@@ -21,6 +21,12 @@ Command kinds and their answers (the ack's ``data``):
* ``fs`` ``{"op": "list"|"stat"|"read"|"write"|"mkdir", "path", …}``: ``read`` takes ``offset``/``length`` and answers
* ``fs`` ``{"op": "list"|"stat"|"read"|"write"|"mkdir", "path", …}``: ``read`` takes ``offset``/``length`` and answers
``content_b64``; ``write`` takes ``content_b64`` and ``mode`` (``create``, ``overwrite`` or ``append``).
``content_b64``; ``write`` takes ``content_b64`` and ``mode`` (``create``, ``overwrite`` or ``append``).
* ``screen`` ``{"max_width"?}`` → ``{format, width, height, content_b64}`` (JPEG through Pillow when it is installed).
* ``screen`` ``{"max_width"?}`` → ``{format, width, height, content_b64}`` (JPEG through Pillow when it is installed).
+* ``screen_stream`` ``{"action": "start", "seconds"?, "fps"?}`` → ``{streaming, seconds, fps, tool}``, or ``{"action":
+ "stop"}``: the screen shown live to the machine's owner. The plugin captures a JPEG frame ``fps`` times a second (at
+ most 5) with the same tools as ``screen`` and hands each to the core as video (``stream: "screen"``), which publishes
+ it to the owner's private stream; the core alone holds the WHIP URL and key. It runs at every level, like a
+ screenshot, says so on the desktop when it starts (config ``notify``), and ends when its time is up (30 s to 30 min),
+ on stop, and at once on the local kill switch or an e-stop. Config ``allow_screen_stream: false`` turns it off.
* ``input`` ``{"action", "x"?, "y"?, "width"?, "height"?, "dy"?, "text"?, "keys"?}`` → ``{done, action, x?, y?}``: move,
* ``input`` ``{"action", "x"?, "y"?, "width"?, "height"?, "dy"?, "text"?, "keys"?}`` → ``{done, action, x?, y?}``: move,
click, double_click or right_click at a point, scroll by ``dy`` clicks, type ``text`` into whatever has focus, or press
click, double_click or right_click at a point, scroll by ``dy`` clicks, type ``text`` into whatever has focus, or press
one key or chord (``keys``, for example ``ctrl+s``). ``x``/``y`` are in the space of a screen picture ``width`` ×
one key or chord (``keys``, for example ``ctrl+s``). ``x``/``y`` are in the space of a screen picture ``width`` ×
@@ -79,6 +85,11 @@ INPUT_GUARD_S = 2.0
@@ -79,6 +85,11 @@ INPUT_GUARD_S = 2.0
MAX_OUTPUT_CAP = 192 * 1024
MAX_OUTPUT_CAP = 192 * 1024
MAX_READ_CAP = 256 * 1024
MAX_READ_CAP = 256 * 1024
SCREEN_MAX_BYTES = 300 * 1024
SCREEN_MAX_BYTES = 300 * 1024
+# The live screen: frame width (Pillow resizes larger screens) and JPEG quality; the stream ends after this many
+# capture failures in a row.
+STREAM_WIDTH = 1600
+STREAM_QUALITY = 70
+STREAM_MAX_FAILURES = 5
INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scroll", "type", "key"})
INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scroll", "type", "key"})
BROWSER_READS = frozenset({"read", "screenshot", "close"})
BROWSER_READS = frozenset({"read", "screenshot", "close"})
BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back"})
BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back"})
@@ -121,7 +132,7 @@ def _int(v, name, lo, hi, default):
@@ -121,7 +132,7 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
class Computer(Plugin):
driver = "computer"
driver = "computer"
- version = "0.6.0"
+ version = "0.7.0"
motion_kinds = frozenset()
motion_kinds = frozenset()
telemetry_interval_s = 2.0
telemetry_interval_s = 2.0
@@ -158,6 +169,9 @@ class Computer(Plugin):
@@ -158,6 +169,9 @@ class Computer(Plugin):
self.browser_headless = True
self.browser_headless = True
self.browser_idle_s = 600
self.browser_idle_s = 600
self.browser = None
self.browser = None
+ self.allow_screen_stream = True
+ self.stream = None # the live screen: {"sid", "until", "fps", "tool", "dir", "stop": Event}
+ self.stream_mu = threading.Lock()
self.browser_mu = threading.Lock()
self.browser_mu = threading.Lock()
self.browser_op_mu = threading.Lock()
self.browser_op_mu = threading.Lock()
self.browser_factory = browser_factory or (lambda binary, profile, headless: Browser(binary, profile, headless))
self.browser_factory = browser_factory or (lambda binary, profile, headless: Browser(binary, profile, headless))
@@ -184,6 +198,7 @@ class Computer(Plugin):
@@ -184,6 +198,7 @@ class Computer(Plugin):
self.allow_input = config.get("allow_input", True) is not False
self.allow_input = config.get("allow_input", True) is not False
self.notify = config.get("notify", True) is not False
self.notify = config.get("notify", True) is not False
self.allow_browser = config.get("allow_browser", True) is not False
self.allow_browser = config.get("allow_browser", True) is not False
+ self.allow_screen_stream = config.get("allow_screen_stream", True) is not False
self.browser_path = config.get("browser_path") or None
self.browser_path = config.get("browser_path") or None
self.browser_profile = os.path.expanduser(config.get("browser_profile") or "~/.local/share/openvibe-node/browser")
self.browser_profile = os.path.expanduser(config.get("browser_profile") or "~/.local/share/openvibe-node/browser")
self.browser_headless = config.get("browser_headless", True) is not False
self.browser_headless = config.get("browser_headless", True) is not False
@@ -199,7 +214,8 @@ class Computer(Plugin):
@@ -199,7 +214,8 @@ class Computer(Plugin):
caps["terminal"] = {"level": self.level, "timeout_s": self.timeout_s, "max_output_bytes": self.max_output}
caps["terminal"] = {"level": self.level, "timeout_s": self.timeout_s, "max_output_bytes": self.max_output}
tool = self._screen_tool()
tool = self._screen_tool()
if tool:
if tool:
- caps["screen"] = {"level": self.level, "tool": tool[0]}
+ caps["screen"] = {"level": self.level, "tool": tool[0],
+ "stream": bool(self.allow_screen_stream and self._stream_capable(tool))}
if self.allow_probe:
if self.allow_probe:
caps["probe"] = {"lan_only": True, "schemes": ["http", "https", "tcp"]}
caps["probe"] = {"lan_only": True, "schemes": ["http", "https", "tcp"]}
itool = self._input_tool() if self.allow_input else None
itool = self._input_tool() if self.allow_input else None
@@ -231,6 +247,7 @@ class Computer(Plugin):
@@ -231,6 +247,7 @@ class Computer(Plugin):
for p in procs:
for p in procs:
self._kill(p)
self._kill(p)
self._close_browser()
self._close_browser()
+ self._end_stream("stopped on the machine")
for cid in waiting:
for cid in waiting:
self._close_prompt(cid)
self._close_prompt(cid)
self.emit_event("approval_done", id=cid, approved=False, reason="stopped")
self.emit_event("approval_done", id=cid, approved=False, reason="stopped")
@@ -262,7 +279,9 @@ class Computer(Plugin):
@@ -262,7 +279,9 @@ class Computer(Plugin):
self.emit_event("approval_done", id=cid, approved=False, reason="expired")
self.emit_event("approval_done", id=cid, approved=False, reason="expired")
self.fail(cid, "not_allowed", "nobody answered on the machine in time")
self.fail(cid, "not_allowed", "nobody answered on the machine in time")
# Sensors are what the core forwards to Bot: the machines page shows the level and what waits on the machine.
# Sensors are what the core forwards to Bot: the machines page shows the level and what waits on the machine.
- return {"sensors": {"computer_level": self.effective_level(), "computer_waiting": waiting, "computer_running": running}}
+ streaming = self.stream is not None
+ return {"sensors": {"computer_level": self.effective_level(), "computer_waiting": waiting, "computer_running": running,
+ "computer_screen_stream": "live" if streaming else "off"}}
# ---- paths -----------------------------------------------------------------------------------------------------
# ---- paths -----------------------------------------------------------------------------------------------------
def _inside(self, path):
def _inside(self, path):
@@ -296,6 +315,8 @@ class Computer(Plugin):
@@ -296,6 +315,8 @@ class Computer(Plugin):
raise Fault("unsupported", "no screen capture tool on this machine")
raise Fault("unsupported", "no screen capture tool on this machine")
width = _int(v.get("max_width"), "max_width", 320, 3840, 1280)
width = _int(v.get("max_width"), "max_width", 320, 3840, 1280)
return self._start(cmd, lambda: self._screen(tool, width))
return self._start(cmd, lambda: self._screen(tool, width))
+ if cmd.kind == "screen_stream":
+ return self._screen_stream(v)
if cmd.kind == "fs":
if cmd.kind == "fs":
op = v.get("op")
op = v.get("op")
if op in READ_OPS:
if op in READ_OPS:
@@ -1041,6 +1062,149 @@ class Computer(Plugin):
@@ -1041,6 +1062,149 @@ class Computer(Plugin):
if os.path.exists(out):
if os.path.exists(out):
os.unlink(out)
os.unlink(out)
+ # ---- the live screen --------------------------------------------------------------------------------------------
+ @staticmethod
+ def _have_pillow():
+ try:
+ import PIL.Image # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+ @staticmethod
+ def _native_jpeg(tool, path):
+ """The capture tool writing JPEG itself (for machines without Pillow), or None when it only writes PNG."""
+ name = tool[0]
+ if name == "screencapture":
+ return ["screencapture", "-x", "-t", "jpg", path]
+ if name == "grim":
+ return ["grim", "-t", "jpeg", "-q", str(STREAM_QUALITY), path]
+ if name == "import":
+ return ["import", "-silent", "-window", "root", "-quality", str(STREAM_QUALITY), path]
+ if name == "scrot":
+ return ["scrot", "-o", "-q", str(STREAM_QUALITY), path]
+ return None
+
+ def _stream_capable(self, tool):
+ return self._have_pillow() or self._native_jpeg(tool, "x.jpg") is not None
+
+ def _screen_stream(self, v):
+ action = v.get("action")
+ if action == "stop":
+ return {"streaming": False, "stopped": self._end_stream("stopped by its owner")}
+ if action != "start":
+ raise Fault("bad_value", "action is start or stop")
+ if not self.allow_screen_stream:
+ raise Fault("unsupported", "this machine does not show its screen live (allow_screen_stream is off)")
+ if self.runtime is not None and self.runtime.estopped:
+ raise Fault("local_stop", "stopped on the machine")
+ tool = self._screen_tool()
+ if not tool:
+ raise Fault("unsupported", "no screen capture tool on this machine")
+ if not self._stream_capable(tool):
+ raise Fault("unsupported", "%s only writes PNG: install Pillow (pip install pillow) to show the screen live" % tool[0])
+ seconds = _int(v.get("seconds"), "seconds", 30, 1800, 600)
+ fps = _int(v.get("fps"), "fps", 1, 5, 2)
+ self._end_stream("replaced")
+ frames = tempfile.mkdtemp(prefix="openvibe-screen-stream-") # 0700, this account only
+ stream = {"sid": os.urandom(8).hex(), "until": self.clock() + seconds, "fps": fps, "tool": tool, "dir": frames,
+ "stop": threading.Event()}
+ with self.stream_mu:
+ self.stream = stream
+ threading.Thread(target=self._stream_loop, args=(stream,), name="computer-screen-stream", daemon=True).start()
+ self._notify_stream(seconds)
+ return {"streaming": True, "sid": stream["sid"], "seconds": seconds, "fps": fps, "tool": tool[0]}
+
+ def _end_stream(self, reason):
+ """End the live screen, if one runs → whether one did."""
+ with self.stream_mu:
+ stream, self.stream = self.stream, None
+ if stream is None:
+ return False
+ stream["stop"].set()
+ stream["reason"] = reason
+ return True
+
+ def _stream_loop(self, stream):
+ period = 1.0 / stream["fps"]
+ n, failures, reason = 0, 0, "time is up"
+ try:
+ while not stream["stop"].is_set():
+ started = self.clock()
+ if started >= stream["until"]:
+ break
+ path = os.path.join(stream["dir"], "frame-%d.jpg" % (n % 3)) # the core reads each one at once
+ try:
+ self._stream_frame(stream["tool"], path)
+ n += 1
+ failures = 0
+ self.emit_video(path, stream="screen")
+ except Fault as f:
+ failures += 1
+ if failures >= STREAM_MAX_FAILURES:
+ reason = "the screen capture failed: %s" % f.message
+ break
+ stream["stop"].wait(max(0.0, period - (self.clock() - started)))
+ finally:
+ shutil.rmtree(stream["dir"], ignore_errors=True)
+ with self.stream_mu:
+ if self.stream is stream:
+ self.stream = None
+ # The core ends the stream with the same id (a newer one that replaced it carries its own).
+ self.emit_event("screen_stream_ended", sid=stream["sid"], reason=stream.get("reason") or reason, frames=n)
+
+ def _stream_frame(self, tool, path):
+ """One JPEG frame of the screen at path, at most STREAM_WIDTH wide when Pillow can resize it."""
+ if self._have_pillow():
+ fd, png = tempfile.mkstemp(prefix="openvibe-screen-", suffix=".png")
+ os.close(fd)
+ try:
+ self._capture(tool[1](png))
+ from PIL import Image
+ with Image.open(png) as im:
+ im = im.convert("RGB")
+ if im.width > STREAM_WIDTH:
+ im = im.resize((STREAM_WIDTH, max(1, round(im.height * STREAM_WIDTH / im.width))))
+ im.save(path + ".part", "JPEG", quality=STREAM_QUALITY)
+ os.replace(path + ".part", path)
+ finally:
+ if os.path.exists(png):
+ os.unlink(png)
+ return
+ self._capture(self._native_jpeg(tool, path + ".part.jpg"))
+ os.replace(path + ".part.jpg", path)
+
+ def _capture(self, argv):
+ proc = self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
+ start_new_session=True)
+ try:
+ _, err = proc.communicate(timeout=10)
+ except subprocess.TimeoutExpired:
+ self._kill(proc)
+ raise Fault("hardware", "the screen capture did not finish in 10 s")
+ if proc.returncode != 0:
+ raise Fault("hardware", (err or b"").decode("utf-8", "replace")[:200] or "the capture tool failed")
+
+ def _notify_stream(self, seconds):
+ """Say on the desktop that the screen is live, and how to end it (config `notify`). Best effort."""
+ if not self.notify:
+ return
+ title = "Your screen is live on OpenVibe"
+ body = "Shown to your own account for %d min. Run `openvibe-node stop` to end it now." % max(1, seconds // 60)
+ argv = None
+ if self.platform.startswith("linux") and (self.env.get("DISPLAY") or self.env.get("WAYLAND_DISPLAY")) and self.which("notify-send"):
+ argv = ["notify-send", "--app-name=OpenVibe Node", "--", title, body]
+ elif self.platform == "darwin" and self.which("osascript"):
+ quote = lambda s: '"%s"' % s.replace("\\", "\\\\").replace('"', '\\"')
+ argv = ["osascript", "-e", "display notification %s with title %s" % (quote(body), quote(title))]
+ if argv is None:
+ return
+ try:
+ self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ start_new_session=True)
+ except OSError:
+ pass
+
@staticmethod
@staticmethod
def _encode(path, max_width):
def _encode(path, max_width):
try:
try:
@@ -418,6 +418,81 @@ class ComputerTest(unittest.TestCase):
@@ -418,6 +418,81 @@ class ComputerTest(unittest.TestCase):
self.assertEqual(base64.b64decode(d["content_b64"])[:2], b"\xff\xd8")
self.assertEqual(base64.b64decode(d["content_b64"])[:2], b"\xff\xd8")
+ # ---- the live screen -----------------------------------------------------------------------------------------
+ def boot_stream(self, level="observe", **config):
+ """A plugin whose screen tool copies one JPEG, so frames come without a display."""
+ self.boot(level, notify=False, **config)
+ src = os.path.join(self.dir, "screen.jpg")
+ with open(src, "wb") as f:
+ f.write(b"\xff\xd8\xff\xe0" + b"\x00" * 64 + b"\xff\xd9")
+ self.plugin._screen_tool = lambda: ("scrot", lambda out: [sys.executable, "-c", "import shutil,sys; shutil.copy(sys.argv[1], sys.argv[2])", src, out])
+ self.plugin._have_pillow = lambda: False
+ self.plugin._native_jpeg = lambda tool, path: [sys.executable, "-c", "import shutil,sys; shutil.copy(sys.argv[1], sys.argv[2])", src, path]
+
+ def test_screen_stream_emits_frames_at_every_level_and_stops(self):
+ self.boot_stream("observe")
+ r = reply(self.out, self.cmd("screen_stream", {"action": "start", "seconds": 60, "fps": 5}))
+ self.assertEqual(r["op"], "ack")
+ d = r["data"]
+ self.assertEqual((d["streaming"], d["seconds"], d["fps"], d["tool"]), (True, 60, 5, "scrot"))
+ frame = self.out.wait_for(lambda m: m.get("op") == "video" and m.get("stream") == "screen")
+ self.assertEqual(frame["format"], "jpeg")
+ self.assertTrue(frame["path"].startswith(tempfile.gettempdir()))
+ self.out.wait_for(lambda m: m.get("op") == "video" and m.get("path", "").endswith("frame-1.jpg"))
+ self.assertEqual(self.plugin.poll()["sensors"]["computer_screen_stream"], "live")
+ stop = reply(self.out, self.cmd("screen_stream", {"action": "stop"}))
+ self.assertEqual(stop["data"], {"streaming": False, "stopped": True})
+ ended = self.out.wait_for(lambda m: m.get("op") == "event" and m.get("name") == "screen_stream_ended")
+ self.assertEqual((ended["sid"], ended["reason"]), (d["sid"], "stopped by its owner"))
+ self.assertFalse(os.path.exists(os.path.dirname(frame["path"])), "the frames' folder is removed")
+ self.assertEqual(self.plugin.poll()["sensors"]["computer_screen_stream"], "off")
+
+ def test_screen_stream_ends_on_the_kill_switch_and_is_refused_while_latched(self):
+ self.boot_stream("trusted")
+ first = reply(self.out, self.cmd("screen_stream", {"action": "start", "seconds": 60}))["data"]
+ self.rt.dispatch({"op": "estop"})
+ ended = self.out.wait_for(lambda m: m.get("op") == "event" and m.get("name") == "screen_stream_ended")
+ self.assertEqual((ended["sid"], ended["reason"]), (first["sid"], "stopped on the machine"))
+ r = reply(self.out, self.cmd("screen_stream", {"action": "start"}))
+ self.assertEqual((r["op"], r["fault_code"]), ("nack", "local_stop"))
+
+ def test_screen_stream_replaces_and_ends_on_its_own(self):
+ self.boot_stream()
+ a = reply(self.out, self.cmd("screen_stream", {"action": "start", "seconds": 60}))["data"]
+ b = reply(self.out, self.cmd("screen_stream", {"action": "start", "seconds": 30}))["data"]
+ self.assertNotEqual(a["sid"], b["sid"])
+ ended = self.out.wait_for(lambda m: m.get("op") == "event" and m.get("name") == "screen_stream_ended")
+ self.assertEqual((ended["sid"], ended["reason"]), (a["sid"], "replaced"))
+ # Time up: a clock moved past the end stops the frames.
+ self.plugin.stream["until"] = 0
+ late = self.out.wait_for(lambda m: m.get("op") == "event" and m.get("name") == "screen_stream_ended" and m.get("sid") == b["sid"])
+ self.assertEqual(late["reason"], "time is up")
+
+ def test_screen_stream_refusals(self):
+ self.boot("observe", allow_screen_stream=False)
+ self.plugin._screen_tool = lambda: ("scrot", lambda out: ["true"])
+ r = reply(self.out, self.cmd("screen_stream", {"action": "start"}))
+ self.assertEqual(r["fault_code"], "unsupported")
+ self.boot("observe")
+ r = reply(self.out, self.cmd("screen_stream", {"action": "start"}))
+ self.assertEqual((r["fault_code"], r["message"]), ("unsupported", "no screen capture tool on this machine"))
+ self.plugin._screen_tool = lambda: ("gnome-screenshot", lambda out: ["true"])
+ self.plugin._have_pillow = lambda: False
+ r = reply(self.out, self.cmd("screen_stream", {"action": "start"}))
+ self.assertIn("install Pillow", r["message"])
+ r = reply(self.out, self.cmd("screen_stream", {"action": "watch"}))
+ self.assertEqual(r["fault_code"], "bad_value")
+
+ def test_screen_stream_capability_and_native_jpeg_tools(self):
+ c = Computer(env={}, platform="linux")
+ self.assertEqual(c._native_jpeg(("grim", None), "/t/f.jpg"), ["grim", "-t", "jpeg", "-q", "70", "/t/f.jpg"])
+ self.assertEqual(c._native_jpeg(("screencapture", None), "/t/f.jpg"), ["screencapture", "-x", "-t", "jpg", "/t/f.jpg"])
+ self.assertIsNone(c._native_jpeg(("gnome-screenshot", None), "/t/f.jpg"))
+ which = lambda name: "/usr/bin/" + name if name == "grim" else None
+ w = Computer(which=which, env={"WAYLAND_DISPLAY": "wayland-0"}, platform="linux")
+ self.assertTrue(w.describe({"level": "observe"})["screen"]["stream"])
+ self.assertFalse(w.describe({"level": "observe", "allow_screen_stream": False})["screen"]["stream"])
+
# ---- input ---------------------------------------------------------------------------------------------------
# ---- input ---------------------------------------------------------------------------------------------------
def boot_input(self, level, platform="linux", tool="xdotool", size=(2560, 1440)):
def boot_input(self, level, platform="linux", tool="xdotool", size=(2560, 1440)):
self.argvs = []
self.argvs = []