openvibers/openvibe.link Commits

Node: approvals from the desktop — openvibe-node tray (Linux, Windows), Approve/Deny notifications and a macOS dialog; approvals wait out the agent's own clicks (#47)

OP OpenVibers committed 29 days ago · da824eea75 · 23 files, +1843 −16

* Node: approvals from the desktop — openvibe-node tray (Linux, Windows), Approve/Deny notifications and a macOS dialog; approvals wait out the agent's own clicks

Approvals for the computer plugin at level `ask`, answered from the desktop. Before this, the only way was `openvibe-node approve <id>`.

**`openvibe-node tray`** (Linux and Windows, pure Go through fyne.io/systray, no cgo):
- **Icon:** blue when all is well, amber while a request waits, red while a stop is latched, grey when the Node does not answer.
- **Menu:** lists each waiting request (its kind and what it would do) with **Approve** and **Deny**, then **Stop everything** (the kill switch), **Resume** and the machines page on the Bot the Node is paired with (https only, or loopback http).
- **Notifications (Linux):** a new request raises one over org.freedesktop.Notifications with Approve and Deny buttons. Only the notification server's own signals count, and the body is escaped when the server reads markup.
- **Autostart:** `--autostart on|off` uses an XDG entry on Linux and a Run value on Windows. The installer's `--computer` turns it on.
- **Connection:** the tray runs in the desktop session and talks to the Node over the local socket, like the other commands (new `tray` command).
- **macOS:** the release builds without cgo, so there is no tray there; the plugin asks with a dialog instead (below).

**The plugin's own prompts** (when no tray runs):
- **Linux:** a notification with Approve and Deny buttons (`notify-send --wait --action`, libnotify 0.7.10+; older versions get the command to answer with, as before).
- **macOS:** a dialog whose default button is **Deny**. It gives up when the request expires.
- A prompt is taken down when its request is answered elsewhere, expires or is stopped.
- While the tray says it is there, the core sends the plugin `{"op": "tray", "attached", "ttl_s"}` (at most every 5 s, and at once on quit), and the plugin leaves prompts to the tray.

**Approvals and the agent's own clicks:**
- `approve` is refused while an `input` command runs (one in flight and not waiting for approval) and for 2 s after one ran.
- The core refuses it with "answer again in a moment"; the plugin re-asks for its own prompts. The tray then asks again.
- Deny always goes through.
- This means an approved click (whose summary shows coordinates, not what is under them) cannot press another request's Approve button in the tray, a notification or the dialog.

**Tests:**
- Core: the guard and the tray presence (`internal/node`).
- Tray: the model, icons (PNG and a 32-bit ICO), the desktop entry and panel URL, and a full poll and answer cycle against a fake Node on the socket.
- The D-Bus notifier against a fake notification server on a private bus, including an impostor's button signal being ignored. A new CI step runs it under `dbus-run-session`.
- Plugin: buttons, deny, closing on an answer elsewhere, the guard, the tray hand-off and the macOS dialog.
- SDK: the `tray` op.

**Checks:** `go test -race ./...` passes. The cross-builds for linux amd64/arm64/armv7, darwin and windows pass, as do the sdk and computer Python suites. A smoke run of the real tray on a private session bus starts, logs that there is no tray host, and exits 0 on SIGTERM.

* Node test: the mouse-guard test's clock starts at the real clock (the command's deadline counts on it; CI runners have a small monotonic clock)

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 3fb28b3..12eca0a 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -28,6 +28,12 @@ jobs:
# (and Ubuntu's AppArmor restricts unprivileged user namespaces).
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
- run: go test -race -count=1 ./...
++ # The tray's desktop notifications, against a fake notification server on a private session bus.
++ - name: tray notifications (private D-Bus)
++ run: |
++ command -v dbus-run-session >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq dbus; }
++ dbus-run-session -- go test -race -count=1 -v -run DesktopNotifier ./cmd/openvibe-node | tee /tmp/notify.log
++ grep -q -- '--- PASS: TestDesktopNotifierAsksAndHearsTheButtons' /tmp/notify.log
# So they run again as root (jobs as nobody), in a systemd scope with its cgroup delegated, where
# OPENVIBE_WORKER_TESTS=require makes them fail instead of skipping. Each test binary lies alone in a directory
# nobody can read, as a function's executable must (a job gets a copy of that directory).
ddiff --git a/README.md b/README.md
iindex 5f1fe82..5f55468 100644
---- a/README.md
++++ b/README.md
@@@ -18,6 +18,7 @@ openvibe-node status # link, plugins, video, battery, stop latch
openvibe-node stop # kill switch: stop every motor now and keep it stopped
openvibe-node resume
openvibe-node approvals # the computer plugin at level ask: what waits; approve <id> / deny <id>
++openvibe-node tray # the same in your notification area, with Approve/Deny notifications (Linux, Windows)
```
## What is in here
@@@ -43,7 +44,7 @@ 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 for
--`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)).
**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
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex 0fb6b73..b56908c 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -60,6 +60,9 @@ Usage:
openvibe-node approvals [--json] what the computer plugin waits to be approved (control level "ask")
openvibe-node approve <ID> let it run
openvibe-node deny <ID> refuse it
++ openvibe-node tray [--autostart on|off]
++ the Node in your notification area (Linux, Windows): what waits with Approve and Deny, stop,
++ resume; --autostart starts it when you sign in
openvibe-node profile verify community.<name> [--server URL] [--key-id ID]
check a community robot profile against the signature Bot made when it was reviewed
openvibe-node version
@@@ -110,6 +113,7 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
svcUser = fs.String("user", "", "run the service as this account (install)")
session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")
keyID = fs.String("key-id", "", "accept only this Bot signing key id (profile verify)")
++ autoArg = fs.String("autostart", "", "on or off: start the tray when you sign in (tray)")
)
// Accept flags anywhere: split positionals out.
var positional []string
@@@ -189,6 +193,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
return 2
}
err = cmdAnswer(g, cmd, positional[0], stdout)
++ case "tray":
++ err = cmdTray(g, *autoArg, stdout)
case "profile":
if len(positional) != 2 || positional[0] != "verify" {
fmt.Fprintln(stderr, "usage: openvibe-node profile verify community.<name> [--server URL] [--key-id ID]")
ddiff --git a/cmd/openvibe-node/tray.go b/cmd/openvibe-node/tray.go
nnew file mode 100644
iindex 0000000..668cc27
---- /dev/null
++++ b/cmd/openvibe-node/tray.go
@@@ -0,0 +1,460 @@
++package main
++
++// `openvibe-node tray`: the Node in its owner's notification area. It runs in the graphical session (not in the
++// service), talks to the running Node over the local control socket like the other commands, and shows:
++//
++// - the state in the icon: blue when all is well, amber while a computer request waits for an answer, red while a
++// stop is latched, grey when the Node does not answer;
++// - what waits for approval (the computer plugin at level "ask"), each with Approve and Deny;
++// - Stop everything (the local kill switch), Resume, and the machines page on openvibe.bot.
++//
++// A new request also raises a desktop notification with Approve and Deny buttons where the desktop offers them
++// (Linux, org.freedesktop.Notifications). While the tray runs, the computer plugin's own prompts stay quiet. The core
++// refuses an Approve while the agent drives the mouse or keyboard and just after (node.InputGuard), so an approved
++// click cannot press Approve here; the tray then asks again.
++
++import (
++ "bytes"
++ "encoding/binary"
++ "encoding/json"
++ "errors"
++ "fmt"
++ "image"
++ "image/color"
++ "image/png"
++ "io"
++ "math"
++ "net/url"
++ "os"
++ "path/filepath"
++ "strings"
++ "sync"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
++ "github.com/OpenVibers/OpenVibe.Node/internal/node"
++)
++
++// trayPoll is how often the tray asks the Node what changed.
++const trayPoll = 1500 * time.Millisecond
++
++// traySlots is how many waiting requests the menu lists (the plugin lets 8 wait at once).
++const traySlots = 8
++
++// Tray states, which pick the icon.
++const (
++ trayOK = "ok"
++ trayWaiting = "waiting"
++ trayStopped = "stopped"
++ trayDown = "down"
++)
++
++// trayView is what the tray shows for one poll.
++type trayView struct {
++ State string
++ Tooltip string
++ Header string // the first, disabled menu line
++ Level string // the computer plugin's level, "" without it
++ Stopped bool
++ Waiting []node.Approval
++ Panel string // the machines page on the Bot this Node is paired with, "" if unknown
++}
++
++// viewOf builds the view from the Node's status and approvals; err is the socket error (the Node is not running).
++func viewOf(st *node.Status, list []node.Approval, err error) trayView {
++ if err != nil || st == nil {
++ return trayView{State: trayDown, Tooltip: "OpenVibe Node: not running", Header: "The Node is not running"}
++ }
++ v := trayView{Waiting: list, Panel: panelURL(st.Server)}
++ if lv, ok := st.Telemetry.Sensors["computer_level"].(string); ok {
++ v.Level = lv
++ }
++ v.Stopped = st.Latch.Local || st.Latch.Remote
++ link := "offline"
++ if st.Link.Connected {
++ link = "online"
++ }
++ switch {
++ case v.Stopped:
++ v.State = trayStopped
++ v.Header = "Stopped: observe only"
++ case len(list) > 0:
++ v.State = trayWaiting
++ v.Header = fmt.Sprintf("%d %s your answer", len(list), plural(len(list), "request waits for", "requests wait for"))
++ default:
++ v.State = trayOK
++ v.Header = "OpenVibe Node: " + link
++ }
++ tip := []string{"OpenVibe Node: " + link}
++ if v.Level != "" {
++ tip = append(tip, "computer level "+v.Level)
++ }
++ if v.Stopped {
++ tip = append(tip, "stopped")
++ }
++ if len(list) > 0 {
++ tip = append(tip, fmt.Sprintf("%d waiting", len(list)))
++ }
++ v.Tooltip = strings.Join(tip, ", ")
++ return v
++}
++
++func plural(n int, one, many string) string {
++ if n == 1 {
++ return one
++ }
++ return many
++}
++
++// panelURL is the machines page of the Bot the Node is paired with: an https origin, or a loopback http one.
++func panelURL(server string) string {
++ u, err := url.Parse(strings.TrimSpace(server))
++ if err != nil || u.Host == "" {
++ return ""
++ }
++ host := u.Hostname()
++ if u.Scheme != "https" && !(u.Scheme == "http" && (host == "127.0.0.1" || host == "localhost" || host == "::1")) {
++ return ""
++ }
++ return u.Scheme + "://" + u.Host + "/machines"
++}
++
++// slotLabel is one waiting request in the menu: its kind and what it would do, cut to fit a menu.
++func slotLabel(a node.Approval) string {
++ s := strings.Join(strings.Fields(a.Summary), " ")
++ if len([]rune(s)) > 70 {
++ s = string([]rune(s)[:69]) + "…"
++ }
++ if a.Kind != "" {
++ return a.Kind + ": " + s
++ }
++ return s
++}
++
++// trayUI is the notification-area side (systray on Linux and Windows; a fake in tests).
++type trayUI interface {
++ SetIcon(state string)
++ SetTooltip(string)
++ SetHeader(string)
++ SetLevel(string)
++ SetStopped(bool)
++ SetSlots([]string) // one label per waiting request, at most traySlots; the rest hidden
++ SetPanel(bool)
++}
++
++// notifier raises and takes down desktop notifications (D-Bus on Linux; nothing elsewhere).
++type notifier interface {
++ // Ask shows a request with Approve and Deny; again marks a second asking after a refused answer.
++ Ask(a node.Approval, again string)
++ Close(id string)
++ Info(title, body string)
++ // Answers carries {id, approved} when a notification's button is pressed; nil if there are none.
++ Answers() <-chan trayAnswer
++ Shut()
++}
++
++type trayAnswer struct {
++ ID string
++ Approved bool
++}
++
++type nopNotifier struct{}
++
++func (nopNotifier) Ask(node.Approval, string) {}
++func (nopNotifier) Close(string) {}
++func (nopNotifier) Info(string, string) {}
++func (nopNotifier) Answers() <-chan trayAnswer { return nil }
++func (nopNotifier) Shut() {}
++
++// trayCore polls the Node and drives the UI and the notifications; the platform files only wire it up.
++type trayCore struct {
++ sock string
++ ui trayUI
++ note notifier
++ mu sync.Mutex
++ view trayView
++ shown map[string]bool // approval ids with a notification up
++}
++
++func newTrayCore(sock string, ui trayUI, note notifier) *trayCore {
++ if note == nil {
++ note = nopNotifier{}
++ }
++ return &trayCore{sock: sock, ui: ui, note: note, shown: map[string]bool{}}
++}
++
++func (t *trayCore) call(req localctl.Request) (localctl.Response, error) {
++ return localctl.Call(t.sock, req, 2*time.Second)
++}
++
++// tick asks the Node for its status and approvals, says the tray is there, and updates everything.
++func (t *trayCore) tick() trayView {
++ var st *node.Status
++ var list []node.Approval
++ resp, err := t.call(localctl.Request{Cmd: localctl.CmdStatus})
++ if err == nil {
++ var s node.Status
++ if err = json.Unmarshal(resp.Data, &s); err == nil {
++ st = &s
++ }
++ }
++ if err == nil {
++ if resp, aerr := t.call(localctl.Request{Cmd: localctl.CmdApprovals}); aerr == nil {
++ _ = json.Unmarshal(resp.Data, &list)
++ }
++ _, _ = t.call(localctl.Request{Cmd: localctl.CmdTray, On: true})
++ }
++ v := viewOf(st, list, err)
++ t.mu.Lock()
++ t.view = v
++ fresh, gone := []node.Approval{}, []string{}
++ now := map[string]bool{}
++ for _, a := range list {
++ now[a.ID] = true
++ if !t.shown[a.ID] {
++ fresh = append(fresh, a)
++ t.shown[a.ID] = true
++ }
++ }
++ for id := range t.shown {
++ if !now[id] {
++ gone = append(gone, id)
++ delete(t.shown, id)
++ }
++ }
++ t.mu.Unlock()
++ for _, id := range gone {
++ t.note.Close(id)
++ }
++ for _, a := range fresh {
++ t.note.Ask(a, "")
++ }
++ labels := make([]string, 0, traySlots)
++ for i, a := range v.Waiting {
++ if i == traySlots {
++ break
++ }
++ labels = append(labels, slotLabel(a))
++ }
++ t.ui.SetIcon(v.State)
++ t.ui.SetTooltip(v.Tooltip)
++ t.ui.SetHeader(v.Header)
++ t.ui.SetLevel(v.Level)
++ t.ui.SetStopped(v.Stopped)
++ t.ui.SetSlots(labels)
++ t.ui.SetPanel(v.Panel != "")
++ return v
++}
++
++// slotID is the approval id the menu's slot i shows now ("" if none).
++func (t *trayCore) slotID(i int) (node.Approval, bool) {
++ t.mu.Lock()
++ defer t.mu.Unlock()
++ if i < 0 || i >= len(t.view.Waiting) {
++ return node.Approval{}, false
++ }
++ return t.view.Waiting[i], true
++}
++
++// answer approves or denies one request; a refusal (the agent just used the mouse or keyboard) asks again.
++func (t *trayCore) answer(a node.Approval, approved bool) error {
++ cmd := localctl.CmdDeny
++ if approved {
++ cmd = localctl.CmdApprove
++ }
++ _, err := t.call(localctl.Request{Cmd: cmd, ID: a.ID})
++ if err != nil {
++ if approved && strings.Contains(err.Error(), "mouse or keyboard") {
++ t.note.Ask(a, "Not taken: the agent was using the mouse or keyboard. Answer again if it was you.")
++ } else {
++ t.note.Info("OpenVibe Node", err.Error())
++ }
++ return err
++ }
++ t.mu.Lock()
++ delete(t.shown, a.ID)
++ t.mu.Unlock()
++ t.note.Close(a.ID)
++ return nil
++}
++
++// answerByID is a notification's button: the request must still wait.
++func (t *trayCore) answerByID(id string, approved bool) error {
++ t.mu.Lock()
++ var found *node.Approval
++ for i := range t.view.Waiting {
++ if t.view.Waiting[i].ID == id {
++ a := t.view.Waiting[i]
++ found = &a
++ }
++ }
++ t.mu.Unlock()
++ if found == nil {
++ return errors.New("it no longer waits")
++ }
++ return t.answer(*found, approved)
++}
++
++func (t *trayCore) stop() error {
++ _, err := t.call(localctl.Request{Cmd: localctl.CmdStop})
++ return err
++}
++
++func (t *trayCore) resume() error {
++ _, err := t.call(localctl.Request{Cmd: localctl.CmdResume})
++ return err
++}
++
++// quit tells the Node the tray is gone (the plugin prompts again) and takes the notifications down.
++func (t *trayCore) quit() {
++ _, _ = t.call(localctl.Request{Cmd: localctl.CmdTray, On: false})
++ t.mu.Lock()
++ ids := make([]string, 0, len(t.shown))
++ for id := range t.shown {
++ ids = append(ids, id)
++ }
++ t.shown = map[string]bool{}
++ t.mu.Unlock()
++ for _, id := range ids {
++ t.note.Close(id)
++ }
++ t.note.Shut()
++}
++
++func (t *trayCore) panel() string {
++ t.mu.Lock()
++ defer t.mu.Unlock()
++ return t.view.Panel
++}
++
++// cmdTray runs the tray, or with --autostart on|off adds it to (or removes it from) the session's start.
++func cmdTray(g *globals, autostart string, stdout io.Writer) error {
++ switch autostart {
++ case "":
++ return runTray(g)
++ case "on", "off":
++ exe, err := os.Executable()
++ if err != nil {
++ return err
++ }
++ return trayAutostart(autostart == "on", exe, g.home, stdout)
++ }
++ return fmt.Errorf("--autostart is on or off")
++}
++
++// trayDesktopEntry is the XDG autostart entry that starts the tray when its owner signs in (Linux).
++func trayDesktopEntry(exe, home string) string {
++ line := shellQuote(exe) + " tray"
++ if home != "" {
++ line += " --home " + shellQuote(home)
++ }
++ return "[Desktop Entry]\nType=Application\nName=OpenVibe Node\nComment=Approve what your agents ask to do on this computer\n" +
++ "Exec=" + line + "\nIcon=dialog-question\nTerminal=false\nX-GNOME-Autostart-enabled=true\nNoDisplay=false\n"
++}
++
++// shellQuote quotes one Exec argument the way the desktop entry spec reads them.
++func shellQuote(s string) string {
++ if s != "" && !strings.ContainsAny(s, " \t\n\"'\\$`") {
++ return s
++ }
++ r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", `$`, `\$`)
++ return `"` + r.Replace(s) + `"`
++}
++
++// trayAutostartPath is where the Linux autostart entry goes: $XDG_CONFIG_HOME/autostart, else ~/.config/autostart.
++func trayAutostartPath() (string, error) {
++ dir := os.Getenv("XDG_CONFIG_HOME")
++ if dir == "" {
++ home, err := os.UserHomeDir()
++ if err != nil {
++ return "", err
++ }
++ dir = filepath.Join(home, ".config")
++ }
++ return filepath.Join(dir, "autostart", "openvibe-node-tray.desktop"), nil
++}
++
++// ---- icons ---------------------------------------------------------------------------------------------------------
++
++var trayColors = map[string]color.NRGBA{
++ trayOK: {0x3b, 0x82, 0xf6, 0xff}, // the OpenVibe blue
++ trayWaiting: {0xf5, 0x9e, 0x0b, 0xff},
++ trayStopped: {0xdc, 0x26, 0x26, 0xff},
++ trayDown: {0x9c, 0xa3, 0xaf, 0xff},
++}
++
++// trayImage draws the tray icon: a filled disc in the state's colour with a white ring-and-dot (the Node's mark);
++// while something waits the dot becomes a bar, an exclamation.
++func trayImage(state string, size int) *image.NRGBA {
++ img := image.NewNRGBA(image.Rect(0, 0, size, size))
++ c, ok := trayColors[state]
++ if !ok {
++ c = trayColors[trayDown]
++ }
++ white := color.NRGBA{0xff, 0xff, 0xff, 0xff}
++ r := float64(size) / 2
++ for y := 0; y < size; y++ {
++ for x := 0; x < size; x++ {
++ dx, dy := float64(x)+0.5-r, float64(y)+0.5-r
++ d := math.Hypot(dx, dy)
++ a := clamp01(r - d) // anti-aliased edge of the disc
++ if a <= 0 {
++ continue
++ }
++ px := c
++ ring := math.Abs(d - r*0.55)
++ if state == trayWaiting {
++ // an exclamation mark: a bar and a dot
++ if (math.Abs(dx) < r*0.13 && dy > -r*0.55 && dy < r*0.15) || math.Hypot(dx, dy-r*0.42) < r*0.14 {
++ px = white
++ }
++ } else if ring < r*0.1 || d < r*0.18 {
++ px = white
++ }
++ px.A = uint8(float64(px.A) * a)
++ img.SetNRGBA(x, y, px)
++ }
++ }
++ return img
++}
++
++func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }
++
++// trayPNG is the icon for the StatusNotifierItem (Linux), which takes PNG bytes.
++func trayPNG(state string) []byte {
++ var b bytes.Buffer
++ _ = png.Encode(&b, trayImage(state, 64))
++ return b.Bytes()
++}
++
++// trayICO is the icon for Windows: a 32×32 32-bit ICO (BMP image with its AND mask), which LoadImage reads.
++func trayICO(state string) []byte {
++ const size = 32
++ img := trayImage(state, size)
++ var dib bytes.Buffer
++ hdr := struct {
++ Size uint32
++ Width, Height int32
++ Planes, BitCount uint16
++ Compression, SizeImage uint32
++ XPels, YPels, Used, Imported int32
++ }{40, size, size * 2, 1, 32, 0, size * size * 4, 0, 0, 0, 0}
++ _ = binary.Write(&dib, binary.LittleEndian, hdr)
++ for y := size - 1; y >= 0; y-- { // bottom-up rows, BGRA
++ for x := 0; x < size; x++ {
++ p := img.NRGBAAt(x, y)
++ dib.Write([]byte{p.B, p.G, p.R, p.A})
++ }
++ }
++ dib.Write(make([]byte, size*4)) // the AND mask: 32 bits a row, all zero (alpha decides)
++ var out bytes.Buffer
++ _ = binary.Write(&out, binary.LittleEndian, [3]uint16{0, 1, 1}) // ICONDIR: reserved, type icon, one image
++ entry := struct {
++ W, H, Colors, Reserved uint8
++ Planes, BitCount uint16
++ Bytes, Offset uint32
++ }{size, size, 0, 0, 1, 32, uint32(dib.Len()), 6 + 16}
++ _ = binary.Write(&out, binary.LittleEndian, entry)
++ out.Write(dib.Bytes())
++ return out.Bytes()
++}
ddiff --git a/cmd/openvibe-node/tray_notify_linux.go b/cmd/openvibe-node/tray_notify_linux.go
nnew file mode 100644
iindex 0000000..dd1884f
---- /dev/null
++++ b/cmd/openvibe-node/tray_notify_linux.go
@@@ -0,0 +1,172 @@
++//go:build linux
++
++package main
++
++import (
++ "log/slog"
++ "strings"
++ "sync"
++
++ "github.com/godbus/dbus/v5"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/node"
++)
++
++// Desktop notifications over org.freedesktop.Notifications (every Linux desktop): a waiting request with Approve and
++// Deny buttons when the server has "actions", else the command to answer with.
++
++const (
++ notifyName = "org.freedesktop.Notifications"
++ notifyPath = dbus.ObjectPath("/org/freedesktop/Notifications")
++ notifyIface = "org.freedesktop.Notifications"
++)
++
++type dbusNotifier struct {
++ conn *dbus.Conn
++ obj dbus.BusObject
++ actions bool
++ markup bool
++ log *slog.Logger
++ mu sync.Mutex
++ byID map[string]uint32 // approval id → notification id
++ byNote map[uint32]string
++ owner string // the notification server's unique bus name: only its signals count
++ answers chan trayAnswer
++}
++
++func newDesktopNotifier(log *slog.Logger) notifier {
++ conn, err := dbus.ConnectSessionBus()
++ if err != nil {
++ log.Warn("tray: no desktop notifications (no session bus)", "err", err)
++ return nopNotifier{}
++ }
++ n := &dbusNotifier{conn: conn, obj: conn.Object(notifyName, notifyPath), log: log,
++ byID: map[string]uint32{}, byNote: map[uint32]string{}, answers: make(chan trayAnswer, 8)}
++ var caps []string
++ if err := n.obj.Call(notifyIface+".GetCapabilities", 0).Store(&caps); err == nil {
++ for _, c := range caps {
++ n.actions = n.actions || c == "actions"
++ n.markup = n.markup || c == "body-markup"
++ }
++ }
++ _ = conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, notifyName).Store(&n.owner)
++ var host bool
++ if conn.BusObject().Call("org.freedesktop.DBus.NameHasOwner", 0, "org.kde.StatusNotifierWatcher").Store(&host) == nil && !host {
++ log.Warn("tray: this desktop shows no tray icons (on GNOME, the AppIndicator extension adds them); the notifications still ask")
++ }
++ if err := conn.AddMatchSignal(dbus.WithMatchInterface(notifyIface), dbus.WithMatchObjectPath(notifyPath)); err != nil {
++ log.Warn("tray: notification buttons will not answer", "err", err)
++ }
++ ch := make(chan *dbus.Signal, 16)
++ conn.Signal(ch)
++ go n.listen(ch)
++ return n
++}
++
++func (n *dbusNotifier) listen(ch <-chan *dbus.Signal) {
++ for sig := range ch {
++ if sig == nil || sig.Path != notifyPath {
++ continue
++ }
++ if n.owner != "" && sig.Sender != n.owner {
++ // The server may have restarted: ask who owns the name now before trusting it.
++ var owner string
++ if err := n.conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, notifyName).Store(&owner); err != nil || owner != sig.Sender {
++ continue
++ }
++ n.owner = owner
++ }
++ switch sig.Name {
++ case notifyIface + ".ActionInvoked":
++ if len(sig.Body) != 2 {
++ continue
++ }
++ nid, _ := sig.Body[0].(uint32)
++ key, _ := sig.Body[1].(string)
++ n.mu.Lock()
++ id := n.byNote[nid]
++ n.mu.Unlock()
++ if id == "" || (key != "approve" && key != "deny") {
++ continue
++ }
++ select {
++ case n.answers <- trayAnswer{ID: id, Approved: key == "approve"}:
++ default:
++ }
++ case notifyIface + ".NotificationClosed":
++ if len(sig.Body) < 1 {
++ continue
++ }
++ nid, _ := sig.Body[0].(uint32)
++ n.mu.Lock()
++ if id, ok := n.byNote[nid]; ok {
++ delete(n.byNote, nid)
++ if n.byID[id] == nid {
++ delete(n.byID, id)
++ }
++ }
++ n.mu.Unlock()
++ }
++ }
++}
++
++func (n *dbusNotifier) text(s string) string {
++ if !n.markup {
++ return s
++ }
++ return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(s)
++}
++
++func (n *dbusNotifier) Ask(a node.Approval, again string) {
++ body := a.Summary
++ if again != "" {
++ body = again + "\n" + body
++ }
++ var actions []string
++ if n.actions {
++ actions = []string{"approve", "Approve", "deny", "Deny"}
++ } else {
++ body += "\nopenvibe-node approve " + a.ID + " (or deny " + a.ID + ")"
++ }
++ n.mu.Lock()
++ replaces := n.byID[a.ID]
++ n.mu.Unlock()
++ expire := int32(-1)
++ if a.ExpiresS > 0 {
++ expire = int32(a.ExpiresS * 1000)
++ }
++ hints := map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(2)), "desktop-entry": dbus.MakeVariant("openvibe-node-tray")}
++ var nid uint32
++ err := n.obj.Call(notifyIface+".Notify", 0, "OpenVibe Node", replaces, "dialog-question", "OpenVibe: your agent asks",
++ n.text(body), actions, hints, expire).Store(&nid)
++ if err != nil {
++ n.log.Warn("tray: notification failed", "err", err)
++ return
++ }
++ n.mu.Lock()
++ n.byID[a.ID] = nid
++ n.byNote[nid] = a.ID
++ n.mu.Unlock()
++}
++
++func (n *dbusNotifier) Close(id string) {
++ n.mu.Lock()
++ nid, ok := n.byID[id]
++ delete(n.byID, id)
++ if ok {
++ delete(n.byNote, nid)
++ }
++ n.mu.Unlock()
++ if ok {
++ n.obj.Call(notifyIface+".CloseNotification", 0, nid)
++ }
++}
++
++func (n *dbusNotifier) Info(title, body string) {
++ hints := map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(1))}
++ n.obj.Call(notifyIface+".Notify", 0, "OpenVibe Node", uint32(0), "dialog-information", title, n.text(body), []string{}, hints, int32(6000))
++}
++
++func (n *dbusNotifier) Answers() <-chan trayAnswer { return n.answers }
++
++func (n *dbusNotifier) Shut() { _ = n.conn.Close() }
ddiff --git a/cmd/openvibe-node/tray_notify_linux_test.go b/cmd/openvibe-node/tray_notify_linux_test.go
nnew file mode 100644
iindex 0000000..2a9576c
---- /dev/null
++++ b/cmd/openvibe-node/tray_notify_linux_test.go
@@@ -0,0 +1,112 @@
++//go:build linux
++
++package main
++
++import (
++ "io"
++ "log/slog"
++ "os"
++ "testing"
++ "time"
++
++ "github.com/godbus/dbus/v5"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/node"
++)
++
++// fakeNotifications is an org.freedesktop.Notifications server on a private session bus.
++type fakeNotifications struct {
++ conn *dbus.Conn
++ notify chan []any
++ closed chan uint32
++ next uint32
++}
++
++func (f *fakeNotifications) GetCapabilities() ([]string, *dbus.Error) {
++ return []string{"actions", "body", "body-markup"}, nil
++}
++
++func (f *fakeNotifications) Notify(app string, replaces uint32, icon, summary, body string, actions []string, hints map[string]dbus.Variant, expire int32) (uint32, *dbus.Error) {
++ f.next++
++ id := f.next
++ if replaces != 0 {
++ id = replaces
++ }
++ f.notify <- []any{id, summary, body, actions, expire}
++ return id, nil
++}
++
++func (f *fakeNotifications) CloseNotification(id uint32) *dbus.Error {
++ f.closed <- id
++ return nil
++}
++
++// Run under a private bus: dbus-run-session -- go test ./cmd/openvibe-node -run DesktopNotifier
++func TestDesktopNotifierAsksAndHearsTheButtons(t *testing.T) {
++ if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
++ t.Skip("no session bus (run it under dbus-run-session)")
++ }
++ conn, err := dbus.ConnectSessionBus()
++ if err != nil {
++ t.Skip("session bus:", err)
++ }
++ defer conn.Close()
++ reply, err := conn.RequestName(notifyName, dbus.NameFlagDoNotQueue)
++ if err != nil || reply != dbus.RequestNameReplyPrimaryOwner {
++ t.Skip("a real notification server owns the name: run it under dbus-run-session")
++ }
++ f := &fakeNotifications{conn: conn, notify: make(chan []any, 4), closed: make(chan uint32, 4)}
++ if err := conn.Export(f, notifyPath, notifyIface); err != nil {
++ t.Fatal(err)
++ }
++ n := newDesktopNotifier(slog.New(slog.NewTextHandler(io.Discard, nil)))
++ defer n.Shut()
++ n.Ask(node.Approval{ID: "c1", Kind: "exec", Summary: "run <b>rm</b> & co", ExpiresS: 120}, "")
++ var got []any
++ select {
++ case got = <-f.notify:
++ case <-time.After(5 * time.Second):
++ t.Fatal("no Notify call")
++ }
++ if got[1] != "OpenVibe: your agent asks" || got[2] != "run &lt;b&gt;rm&lt;/b&gt; &amp; co" || got[4] != int32(120000) {
++ t.Fatalf("notify: %v", got)
++ }
++ if a := got[3].([]string); len(a) != 4 || a[0] != "approve" || a[2] != "deny" {
++ t.Fatalf("actions: %v", a)
++ }
++ nid := got[0].(uint32)
++ // Somebody else on the bus faking the button: ignored. The server's own signal: an answer.
++ other, err := dbus.ConnectSessionBus(dbus.WithSignalHandler(dbus.NewSequentialSignalHandler()))
++ if err == nil {
++ _ = other.Emit(notifyPath, notifyIface+".ActionInvoked", nid, "approve")
++ }
++ select {
++ case a := <-n.Answers():
++ t.Fatalf("an impostor's signal answered: %+v", a)
++ case <-time.After(300 * time.Millisecond):
++ }
++ if err := conn.Emit(notifyPath, notifyIface+".ActionInvoked", nid, "approve"); err != nil {
++ t.Fatal(err)
++ }
++ select {
++ case a := <-n.Answers():
++ if a.ID != "c1" || !a.Approved {
++ t.Fatalf("answer: %+v", a)
++ }
++ case <-time.After(5 * time.Second):
++ t.Fatal("the Approve button was not heard")
++ }
++ n.Ask(node.Approval{ID: "c1", Summary: "again"}, "Not taken")
++ if again := <-f.notify; again[0] != nid {
++ t.Fatalf("asking again replaces the same notification: %v", again)
++ }
++ n.Close("c1")
++ select {
++ case id := <-f.closed:
++ if id != nid {
++ t.Fatalf("closed %d, want %d", id, nid)
++ }
++ case <-time.After(5 * time.Second):
++ t.Fatal("not closed")
++ }
++}
ddiff --git a/cmd/openvibe-node/tray_notify_other.go b/cmd/openvibe-node/tray_notify_other.go
nnew file mode 100644
iindex 0000000..549388b
---- /dev/null
++++ b/cmd/openvibe-node/tray_notify_other.go
@@@ -0,0 +1,9 @@
++//go:build !linux
++
++package main
++
++import "log/slog"
++
++// Desktop notifications with buttons are Linux's (org.freedesktop.Notifications); on Windows the icon turns amber and
++// the menu lists what waits.
++func newDesktopNotifier(*slog.Logger) notifier { return nopNotifier{} }
ddiff --git a/cmd/openvibe-node/tray_other.go b/cmd/openvibe-node/tray_other.go
nnew file mode 100644
iindex 0000000..a87dbba
---- /dev/null
++++ b/cmd/openvibe-node/tray_other.go
@@@ -0,0 +1,15 @@
++//go:build !linux && !windows
++
++package main
++
++import (
++ "errors"
++ "io"
++)
++
++// The tray library needs cgo on macOS and the release builds without it: there the computer plugin asks with a
++// dialog for each request instead (docs/computer.md).
++var errTrayPlatform = errors.New("the tray is for Linux and Windows; on macOS the computer plugin asks with a dialog for each request (Approve or Deny)")
++
++func runTray(*globals) error { return errTrayPlatform }
++func trayAutostart(bool, string, string, io.Writer) error { return errTrayPlatform }
ddiff --git a/cmd/openvibe-node/tray_systray.go b/cmd/openvibe-node/tray_systray.go
nnew file mode 100644
iindex 0000000..e5302e4
---- /dev/null
++++ b/cmd/openvibe-node/tray_systray.go
@@@ -0,0 +1,283 @@
++//go:build linux || windows
++
++package main
++
++import (
++ "fmt"
++ "io"
++ "os"
++ "os/exec"
++ "os/signal"
++ "path/filepath"
++ "runtime"
++ "strings"
++ "sync"
++ "syscall"
++ "time"
++
++ "fyne.io/systray"
++)
++
++// The notification area: a StatusNotifierItem over D-Bus on Linux (KDE, XFCE, Cinnamon, MATE, GNOME with the
++// AppIndicator extension) and the notification icon on Windows; both without cgo.
++
++type systrayUI struct {
++ mu sync.Mutex
++ header, level *systray.MenuItem
++ stop, resume, panel *systray.MenuItem
++ quit *systray.MenuItem
++ slots [traySlots]*systray.MenuItem
++ approve, deny [traySlots]*systray.MenuItem
++ state, tooltip, headTx string
++ levelTx string
++ labels []string
++ stopped, hasPanel *bool
++}
++
++func (u *systrayUI) build() {
++ u.header = systray.AddMenuItem("OpenVibe Node", "")
++ u.header.Disable()
++ u.level = systray.AddMenuItem("", "")
++ u.level.Disable()
++ u.level.Hide()
++ systray.AddSeparator()
++ for i := 0; i < traySlots; i++ {
++ u.slots[i] = systray.AddMenuItem("", "Approve or deny what your agent asks")
++ u.approve[i] = u.slots[i].AddSubMenuItem("Approve", "Let it run")
++ u.deny[i] = u.slots[i].AddSubMenuItem("Deny", "Refuse it")
++ u.slots[i].Hide()
++ }
++ systray.AddSeparator()
++ u.stop = systray.AddMenuItem("Stop everything", "The kill switch: stop every actuator, observe only, until you resume")
++ u.resume = systray.AddMenuItem("Resume", "Release the stop")
++ u.panel = systray.AddMenuItem("Open my machines page", "This machine on openvibe.bot")
++ u.panel.Hide()
++ systray.AddSeparator()
++ u.quit = systray.AddMenuItem("Quit the tray", "The Node keeps running; requests wait for `openvibe-node approve`")
++}
++
++func (u *systrayUI) SetIcon(state string) {
++ u.mu.Lock()
++ defer u.mu.Unlock()
++ if state == u.state {
++ return
++ }
++ u.state = state
++ if runtime.GOOS == "windows" {
++ systray.SetIcon(trayICO(state))
++ } else {
++ systray.SetIcon(trayPNG(state))
++ }
++}
++
++func (u *systrayUI) SetTooltip(s string) {
++ u.mu.Lock()
++ defer u.mu.Unlock()
++ if s != u.tooltip {
++ u.tooltip = s
++ systray.SetTooltip(s)
++ }
++}
++
++func (u *systrayUI) SetHeader(s string) {
++ u.mu.Lock()
++ defer u.mu.Unlock()
++ if s != u.headTx {
++ u.headTx = s
++ u.header.SetTitle(s)
++ }
++}
++
++func (u *systrayUI) SetLevel(lv string) {
++ u.mu.Lock()
++ defer u.mu.Unlock()
++ if lv == u.levelTx {
++ return
++ }
++ u.levelTx = lv
++ if lv == "" {
++ u.level.Hide()
++ return
++ }
++ u.level.SetTitle("Computer level: " + lv + " (set on this machine)")
++ u.level.Show()
++}
++
++func (u *systrayUI) SetStopped(stopped bool) {
++ u.mu.Lock()
++ defer u.mu.Unlock()
++ if u.stopped != nil && *u.stopped == stopped {
++ return
++ }
++ u.stopped = &stopped
++ if stopped {
++ u.stop.Disable()
++ u.resume.Enable()
++ } else {
++ u.stop.Enable()
++ u.resume.Disable()
++ }
++}
++
++func (u *systrayUI) SetSlots(labels []string) {
++ u.mu.Lock()
++ defer u.mu.Unlock()
++ if strings.Join(labels, "\x00") == strings.Join(u.labels, "\x00") && u.labels != nil {
++ return
++ }
++ u.labels = append([]string{}, labels...)
++ for i := 0; i < traySlots; i++ {
++ if i < len(labels) {
++ u.slots[i].SetTitle(labels[i])
++ u.slots[i].Show()
++ } else {
++ u.slots[i].Hide()
++ }
++ }
++}
++
++func (u *systrayUI) SetPanel(has bool) {
++ u.mu.Lock()
++ defer u.mu.Unlock()
++ if u.hasPanel != nil && *u.hasPanel == has {
++ return
++ }
++ u.hasPanel = &has
++ if has {
++ u.panel.Show()
++ } else {
++ u.panel.Hide()
++ }
++}
++
++func runTray(g *globals) error {
++ if runtime.GOOS == "linux" && os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" && os.Getenv("XDG_RUNTIME_DIR") == "" {
++ return fmt.Errorf("the tray runs in your desktop session: start it there (openvibe-node tray --autostart on starts it when you sign in)")
++ }
++ ui := &systrayUI{}
++ core := newTrayCore(g.paths.SocketPath(), ui, newDesktopNotifier(g.log))
++ stopSig := make(chan os.Signal, 1)
++ signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM)
++ systray.Run(func() {
++ systray.SetTitle("OpenVibe Node")
++ ui.build()
++ core.tick()
++ go trayLoop(core, ui, stopSig, g)
++ }, core.quit)
++ return nil
++}
++
++func trayLoop(core *trayCore, ui *systrayUI, stopSig <-chan os.Signal, g *globals) {
++ for i := 0; i < traySlots; i++ {
++ i := i
++ go func() {
++ for range ui.approve[i].ClickedCh {
++ if a, ok := core.slotID(i); ok {
++ _ = core.answer(a, true)
++ core.tick()
++ }
++ }
++ }()
++ go func() {
++ for range ui.deny[i].ClickedCh {
++ if a, ok := core.slotID(i); ok {
++ _ = core.answer(a, false)
++ core.tick()
++ }
++ }
++ }()
++ }
++ t := time.NewTicker(trayPoll)
++ defer t.Stop()
++ answers := core.note.Answers()
++ for {
++ select {
++ case <-t.C:
++ core.tick()
++ case a := <-answers:
++ if err := core.answerByID(a.ID, a.Approved); err != nil {
++ g.log.Info("tray answer", "id", a.ID, "err", err)
++ }
++ core.tick()
++ case <-ui.stop.ClickedCh:
++ if err := core.stop(); err != nil {
++ core.note.Info("OpenVibe Node", "Could not stop: "+err.Error())
++ }
++ core.tick()
++ case <-ui.resume.ClickedCh:
++ if err := core.resume(); err != nil {
++ core.note.Info("OpenVibe Node", "Could not resume: "+err.Error())
++ }
++ core.tick()
++ case <-ui.panel.ClickedCh:
++ if u := core.panel(); u != "" {
++ openURL(u)
++ }
++ case <-ui.quit.ClickedCh:
++ systray.Quit()
++ return
++ case <-stopSig:
++ systray.Quit()
++ return
++ }
++ }
++}
++
++// openURL opens the machines page in the default browser.
++func openURL(u string) {
++ var c *exec.Cmd
++ if runtime.GOOS == "windows" {
++ c = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
++ } else {
++ c = exec.Command("xdg-open", u)
++ }
++ _ = c.Start()
++}
++
++// trayAutostart starts the tray when its owner signs in: an XDG autostart entry on Linux, a Run value on Windows.
++func trayAutostart(on bool, exe, home string, stdout io.Writer) error {
++ if runtime.GOOS == "windows" {
++ line := quoteArg(exe) + " tray"
++ if home != "" {
++ line += " --home " + quoteArg(home)
++ }
++ var out []byte
++ var err error
++ if on {
++ out, err = exec.Command("reg", "add", runKey, "/v", "OpenVibe Node tray", "/t", "REG_SZ", "/d", line, "/f").CombinedOutput()
++ } else {
++ out, err = exec.Command("reg", "delete", runKey, "/v", "OpenVibe Node tray", "/f").CombinedOutput()
++ if err != nil && strings.Contains(strings.ToLower(string(out)), "unable to find") {
++ err = nil
++ }
++ }
++ if err != nil {
++ return fmt.Errorf("reg: %v: %s", err, strings.TrimSpace(string(out)))
++ }
++ if on {
++ fmt.Fprintln(stdout, "The tray now starts when you sign in:", line)
++ } else {
++ fmt.Fprintln(stdout, "The tray no longer starts when you sign in.")
++ }
++ return nil
++ }
++ path, err := trayAutostartPath()
++ if err != nil {
++ return err
++ }
++ if !on {
++ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
++ return err
++ }
++ fmt.Fprintln(stdout, "The tray no longer starts when you sign in (removed", path+")")
++ return nil
++ }
++ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
++ return err
++ }
++ if err := os.WriteFile(path, []byte(trayDesktopEntry(exe, home)), 0o644); err != nil {
++ return err
++ }
++ fmt.Fprintln(stdout, "The tray now starts when you sign in:", path)
++ return nil
++}
ddiff --git a/cmd/openvibe-node/tray_test.go b/cmd/openvibe-node/tray_test.go
nnew file mode 100644
iindex 0000000..fc0b48e
---- /dev/null
++++ b/cmd/openvibe-node/tray_test.go
@@@ -0,0 +1,293 @@
++package main
++
++import (
++ "bytes"
++ "context"
++ "encoding/binary"
++ "encoding/json"
++ "errors"
++ "image/png"
++ "os"
++ "path/filepath"
++ "strings"
++ "sync"
++ "testing"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
++ "github.com/OpenVibers/OpenVibe.Node/internal/node"
++ "github.com/OpenVibers/OpenVibe.Node/internal/safety"
++)
++
++func TestTrayViewStates(t *testing.T) {
++ if v := viewOf(nil, nil, errors.New("dial unix: no such file")); v.State != trayDown || !strings.Contains(v.Tooltip, "not running") {
++ t.Fatalf("no Node: %+v", v)
++ }
++ st := &node.Status{Server: "https://openvibe.bot", Link: node.LinkStatus{Connected: true}}
++ st.Telemetry.Sensors = map[string]any{"computer_level": "ask"}
++ v := viewOf(st, nil, nil)
++ if v.State != trayOK || v.Level != "ask" || v.Panel != "https://openvibe.bot/machines" || v.Tooltip != "OpenVibe Node: online, computer level ask" {
++ t.Fatalf("all well: %+v", v)
++ }
++ v = viewOf(st, []node.Approval{{ID: "a"}, {ID: "b"}}, nil)
++ if v.State != trayWaiting || v.Header != "2 requests wait for your answer" || !strings.HasSuffix(v.Tooltip, "2 waiting") {
++ t.Fatalf("waiting: %+v", v)
++ }
++ if v = viewOf(st, []node.Approval{{ID: "a"}}, nil); v.Header != "1 request waits for your answer" {
++ t.Fatalf("one waiting: %q", v.Header)
++ }
++ st.Latch = safety.LatchState{Local: true}
++ if v = viewOf(st, []node.Approval{{ID: "a"}}, nil); v.State != trayStopped || !v.Stopped {
++ t.Fatalf("a latched stop wins over waiting: %+v", v)
++ }
++}
++
++func TestTrayPanelURLOnlyForTheBot(t *testing.T) {
++ for in, want := range map[string]string{
++ "https://openvibe.bot": "https://openvibe.bot/machines",
++ "https://openvibe.bot/api/x": "https://openvibe.bot/machines",
++ "http://127.0.0.1:4100": "http://127.0.0.1:4100/machines",
++ "http://example.com": "",
++ "javascript:alert(1)": "",
++ "": "",
++ "file:///etc/passwd": "",
++ "https://[email protected]:44": "https://openvibe.bot:44/machines", // no user info
++ } {
++ if got := panelURL(in); got != want {
++ t.Errorf("panelURL(%q) = %q, want %q", in, got, want)
++ }
++ }
++}
++
++func TestTraySlotLabel(t *testing.T) {
++ if got := slotLabel(node.Approval{Kind: "exec", Summary: "run echo\nhi"}); got != "exec: run echo hi" {
++ t.Fatalf("label: %q", got)
++ }
++ long := slotLabel(node.Approval{Kind: "input", Summary: strings.Repeat("é", 100)})
++ if !strings.HasSuffix(long, "…") || len([]rune(long)) != len("input: ")+70 {
++ t.Fatalf("long label: %q (%d runes)", long, len([]rune(long)))
++ }
++}
++
++func TestTrayIcons(t *testing.T) {
++ for _, state := range []string{trayOK, trayWaiting, trayStopped, trayDown} {
++ img, err := png.Decode(bytes.NewReader(trayPNG(state)))
++ if err != nil || img.Bounds().Dx() != 64 {
++ t.Fatalf("%s png: %v %v", state, err, img.Bounds())
++ }
++ _, _, _, a := img.At(0, 0).RGBA()
++ if a != 0 {
++ t.Fatalf("%s: the corner is transparent", state)
++ }
++ ico := trayICO(state)
++ if !bytes.Equal(ico[:6], []byte{0, 0, 1, 0, 1, 0}) || ico[6] != 32 || ico[7] != 32 {
++ t.Fatalf("%s ico header: % x", state, ico[:8])
++ }
++ if size := binary.LittleEndian.Uint32(ico[14:18]); int(size) != len(ico)-22 || size != 40+32*32*4+32*4 {
++ t.Fatalf("%s ico image size %d of %d", state, size, len(ico))
++ }
++ if binary.LittleEndian.Uint32(ico[22:26]) != 40 {
++ t.Fatalf("%s: BITMAPINFOHEADER", state)
++ }
++ }
++ ok, _ := png.Decode(bytes.NewReader(trayPNG(trayOK)))
++ waiting, _ := png.Decode(bytes.NewReader(trayPNG(trayWaiting)))
++ r1, g1, b1, _ := ok.At(10, 32).RGBA()
++ r2, g2, b2, _ := waiting.At(10, 32).RGBA()
++ if r1 == r2 && g1 == g2 && b1 == b2 {
++ t.Fatal("waiting and all-well icons look the same")
++ }
++}
++
++func TestTrayDesktopEntry(t *testing.T) {
++ e := trayDesktopEntry("/opt/Open Vibe/openvibe-node", "/home/a/node home")
++ if !strings.Contains(e, `Exec="/opt/Open Vibe/openvibe-node" tray --home "/home/a/node home"`+"\n") || !strings.HasPrefix(e, "[Desktop Entry]\n") {
++ t.Fatalf("entry:\n%s", e)
++ }
++ if e := trayDesktopEntry("/usr/local/bin/openvibe-node", ""); !strings.Contains(e, "Exec=/usr/local/bin/openvibe-node tray\n") {
++ t.Fatalf("entry:\n%s", e)
++ }
++ dir := t.TempDir()
++ t.Setenv("XDG_CONFIG_HOME", dir)
++ if p, err := trayAutostartPath(); err != nil || p != filepath.Join(dir, "autostart", "openvibe-node-tray.desktop") {
++ t.Fatalf("path %q %v", p, err)
++ }
++}
++
++// fakeNode answers the control socket like a running Node with the computer plugin at level ask.
++type fakeNode struct {
++ mu sync.Mutex
++ list []node.Approval
++ refuse int // approvals to refuse as "the agent is using the mouse or keyboard"
++ answered []string
++ tray []bool
++ stopped bool
++}
++
++func (f *fakeNode) handle(r localctl.Request) localctl.Response {
++ f.mu.Lock()
++ defer f.mu.Unlock()
++ switch r.Cmd {
++ case localctl.CmdStatus:
++ st := node.Status{Server: "https://openvibe.bot", Link: node.LinkStatus{Connected: true}, Latch: safety.LatchState{Local: f.stopped}}
++ st.Telemetry.Sensors = map[string]any{"computer_level": "ask"}
++ b, _ := json.Marshal(st)
++ return localctl.Response{OK: true, Data: b}
++ case localctl.CmdApprovals:
++ b, _ := json.Marshal(f.list)
++ return localctl.Response{OK: true, Data: b}
++ case localctl.CmdTray:
++ f.tray = append(f.tray, r.On)
++ return localctl.Response{OK: true}
++ case localctl.CmdApprove, localctl.CmdDeny:
++ if r.Cmd == localctl.CmdApprove && f.refuse > 0 {
++ f.refuse--
++ return localctl.Response{Error: "the agent is using the mouse or keyboard (or just did): answer again in a moment"}
++ }
++ for i, a := range f.list {
++ if a.ID == r.ID {
++ f.list = append(f.list[:i], f.list[i+1:]...)
++ f.answered = append(f.answered, r.Cmd+" "+r.ID)
++ return localctl.Response{OK: true}
++ }
++ }
++ return localctl.Response{Error: "nothing with the id waits"}
++ case localctl.CmdStop:
++ f.stopped = true
++ return localctl.Response{OK: true}
++ case localctl.CmdResume:
++ f.stopped = false
++ return localctl.Response{OK: true}
++ }
++ return localctl.Response{Error: "unknown"}
++}
++
++type fakeUI struct {
++ state, tooltip, header, level string
++ stopped, panel bool
++ slots []string
++}
++
++func (u *fakeUI) SetIcon(s string) { u.state = s }
++func (u *fakeUI) SetTooltip(s string) { u.tooltip = s }
++func (u *fakeUI) SetHeader(s string) { u.header = s }
++func (u *fakeUI) SetLevel(s string) { u.level = s }
++func (u *fakeUI) SetStopped(b bool) { u.stopped = b }
++func (u *fakeUI) SetSlots(l []string) { u.slots = l }
++func (u *fakeUI) SetPanel(b bool) { u.panel = b }
++func (n *fakeNotes) Answers() <-chan trayAnswer { return nil }
++func (n *fakeNotes) Shut() { n.shut = true }
++
++type fakeNotes struct {
++ asked []string // id, or "id again" for a second asking
++ closed []string
++ infos []string
++ shut bool
++}
++
++func (n *fakeNotes) Ask(a node.Approval, again string) {
++ if again != "" {
++ n.asked = append(n.asked, a.ID+" again")
++ return
++ }
++ n.asked = append(n.asked, a.ID)
++}
++func (n *fakeNotes) Close(id string) { n.closed = append(n.closed, id) }
++func (n *fakeNotes) Info(_, body string) { n.infos = append(n.infos, body) }
++
++func TestTrayCoreAgainstANode(t *testing.T) {
++ dir, err := os.MkdirTemp("", "ovt") // short: unix socket paths are limited to ~100 bytes
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer os.RemoveAll(dir)
++ sock := filepath.Join(dir, "n.sock")
++ f := &fakeNode{list: []node.Approval{{ID: "c1", Kind: "exec", Summary: "run echo hi", ExpiresS: 120}}, refuse: 1}
++ ctx, cancel := context.WithCancel(context.Background())
++ defer cancel()
++ go func() { _ = localctl.Serve(ctx, sock, f.handle) }()
++ deadline := time.Now().Add(5 * time.Second)
++ for time.Now().Before(deadline) {
++ if _, err := os.Stat(sock); err == nil {
++ break
++ }
++ time.Sleep(10 * time.Millisecond)
++ }
++ ui, notes := &fakeUI{}, &fakeNotes{}
++ core := newTrayCore(sock, ui, notes)
++
++ core.tick()
++ if ui.state != trayWaiting || ui.level != "ask" || !ui.panel || len(ui.slots) != 1 || ui.slots[0] != "exec: run echo hi" {
++ t.Fatalf("ui after the first poll: %+v", ui)
++ }
++ if strings.Join(notes.asked, ",") != "c1" || len(f.tray) != 1 || !f.tray[0] {
++ t.Fatalf("asked %v, tray %v", notes.asked, f.tray)
++ }
++ core.tick()
++ if len(notes.asked) != 1 {
++ t.Fatalf("asked again without a change: %v", notes.asked)
++ }
++
++ a, ok := core.slotID(0)
++ if !ok || a.ID != "c1" {
++ t.Fatalf("slot 0: %+v %v", a, ok)
++ }
++ if err := core.answer(a, true); err == nil {
++ t.Fatal("the Node refused the approval; the tray should say so")
++ }
++ if strings.Join(notes.asked, ",") != "c1,c1 again" {
++ t.Fatalf("a refused approval asks again: %v", notes.asked)
++ }
++ if err := core.answerByID("c1", true); err != nil {
++ t.Fatal(err)
++ }
++ if strings.Join(f.answered, ",") != "approve c1" || strings.Join(notes.closed, ",") != "c1" {
++ t.Fatalf("answered %v closed %v", f.answered, notes.closed)
++ }
++ core.tick()
++ if ui.state != trayOK || len(ui.slots) != 0 || len(notes.closed) != 1 {
++ t.Fatalf("after the answer: ui %+v closed %v", ui, notes.closed)
++ }
++
++ // A request answered elsewhere (the CLI, or it expired) takes its notification down at the next poll.
++ f.mu.Lock()
++ f.list = append(f.list, node.Approval{ID: "c2", Kind: "input", Summary: "click at 1, 2"})
++ f.mu.Unlock()
++ core.tick()
++ f.mu.Lock()
++ f.list = nil
++ f.mu.Unlock()
++ core.tick()
++ if strings.Join(notes.asked, ",") != "c1,c1 again,c2" || strings.Join(notes.closed, ",") != "c1,c2" {
++ t.Fatalf("asked %v closed %v", notes.asked, notes.closed)
++ }
++ if err := core.answerByID("c2", false); err == nil {
++ t.Fatal("answered a request that no longer waits")
++ }
++
++ if err := core.stop(); err != nil {
++ t.Fatal(err)
++ }
++ core.tick()
++ if ui.state != trayStopped || !ui.stopped {
++ t.Fatalf("stopped: %+v", ui)
++ }
++ if err := core.resume(); err != nil {
++ t.Fatal(err)
++ }
++ core.tick()
++ if ui.state != trayOK || ui.stopped {
++ t.Fatalf("resumed: %+v", ui)
++ }
++
++ core.quit()
++ if last := f.tray[len(f.tray)-1]; last || !notes.shut {
++ t.Fatalf("quit tells the Node and shuts the notifier: tray %v shut %v", f.tray, notes.shut)
++ }
++ cancel()
++ time.Sleep(50 * time.Millisecond)
++ core.tick()
++ if ui.state != trayDown {
++ t.Fatalf("the Node gone: %+v", ui)
++ }
++}
ddiff --git a/docs/computer.md b/docs/computer.md
iindex e9f77ba..a27a700 100644
---- a/docs/computer.md
++++ b/docs/computer.md
@@@ -44,7 +44,30 @@ openvibe-node approve <ID> # let it run
openvibe-node deny <ID> # refuse it
```
--When the Node runs in your graphical session, a waiting request also shows a desktop notification (`notify-send` on Linux, `osascript` on macOS; config `notify: false` turns it off). At most 8 requests wait at once. The panel on openvibe.bot sees `approval` and `approval_done` events, so it can say
++**The tray** (Linux and Windows): `openvibe-node tray` puts the Node in your notification area. The installer's
++`--computer` starts it when you sign in (`openvibe-node tray --autostart on|off` does it by hand). It runs in your
++desktop session, not in the service, and talks to the Node over the same local socket as the commands above:
++
++- the icon is blue when all is well, amber while a request waits, red while a stop is latched, grey when the Node does
++ not answer;
++- the menu lists what waits (kind and what it would do), each with **Approve** and **Deny**, then **Stop everything**
++ (the kill switch), **Resume** and your machines page on openvibe.bot;
++- a new request raises a notification with **Approve** and **Deny** buttons (Linux, any desktop with
++ org.freedesktop.Notifications; without buttons it names the command to answer with).
++
++KDE, XFCE, Cinnamon and MATE show tray icons; GNOME needs the AppIndicator extension, and its notifications still ask.
++While the tray runs, the plugin's own prompts below stay quiet.
++
++**Without the tray**, when the Node runs in your graphical session, the plugin asks itself (config `notify: false` turns
++it off): on Linux a notification with Approve and Deny buttons (`notify-send` from libnotify 0.7.10 on; older ones show
++the command to answer with); on macOS a dialog with **Deny** as its default button, which gives up when the request
++expires. A prompt goes away once the request is answered anywhere else, expires or is stopped.
++
++**An approval is not taken while the agent drives the mouse or keyboard**, nor for 2 seconds after: an approved click
++must not be able to press another request's Approve button. `openvibe-node approve` then answers "answer again in a
++moment", and the tray and the plugin ask again. Deny always goes through.
++
++At most 8 requests wait at once. The panel on openvibe.bot sees `approval` and `approval_done` events, so it can say
that a request is waiting on the machine.
**Kill switch:** `openvibe-node stop` (and an owner's e-stop) drops the plugin to `observe`: what runs is killed, what
@@@ -94,7 +117,7 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
| `max_output_bytes` | 65536 (≤ 192 KB) | stdout and stderr are each cut there |
| `max_read_bytes` | 262144 (≤ 256 KB) | the most one read or write moves |
| `approval_timeout_s` | 120 (≤ 600) | how long a request waits for you |
--| `notify` | `true` | a desktop notification when a request waits (graphical session only) |
++| `notify` | `true` | the plugin's own prompt when a request waits and no tray shows it (graphical session only) |
| `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) |
ddiff --git a/docs/plugins.md b/docs/plugins.md
iindex 0aa9157..f966eca 100644
---- a/docs/plugins.md
++++ b/docs/plugins.md
@@@ -53,6 +53,8 @@ describes itself and waits for EOF without opening any hardware.
| `stop` | | stop every actuator |
| `estop` | | stop every actuator and refuse motion until `resume` |
| `resume` | | accept motion again |
++| `approval` | `id`, `approved` bool | the owner's answer to an `approval` event it raised |
++| `tray` | `attached` bool, `ttl_s` | (computer plugin only) the tray shows its approvals for `ttl_s` s unless told again: its own desktop prompts may stay quiet |
`value` has already been validated and clamped to the owner's limits by the core ([protocol.md](protocol.md#commands)).
`deadline_ms` is relative to receipt and already capped.
@@@ -242,8 +244,10 @@ with the owner's control level (`observe`, `ask`, `trusted`, `full`) and folders
while a stop is latched). Its `ack` carries the answer in `data`. The level is enforced in the plugin from its local
config, never from a command; `ask` holds a command (`Deferred` in the SDK), raises an `approval` event and answers
once the owner runs `openvibe-node approve|deny <id>`, which the core forwards to the plugin as `{"op": "approval", "id",
--"approved"}`. The local kill switch drops it to `observe`. Keyboard and mouse input, a browser and the machine's own
--coding agents are later kinds on the same plugin.
++"approved"}`. The local kill switch drops it to `observe`. `input` (mouse and keyboard) and `browser` are kinds on the
++same plugin; the machine's own coding agents come later. While `openvibe-node tray` runs, the core sends the plugin
++`{"op": "tray", "attached": true, "ttl_s": 15}` every few seconds (and `attached: false` when it quits), and the plugin
++leaves the desktop prompts to the tray.
**SDK additions it uses:** `handle()` may return a dict (the ack's `data`) or raise `Deferred` and answer later with
`complete(id, data)` / `fail(id, code, message)` from any thread; `on_approval(id, approved)`; kinds that move nothing
ddiff --git a/go.mod b/go.mod
iindex ce39914..3a189c7 100644
---- a/go.mod
++++ b/go.mod
@@@ -3,6 +3,8 @@ module github.com/OpenVibers/OpenVibe.Node
go 1.24.7
require (
++ fyne.io/systray v1.12.2
++ github.com/godbus/dbus/v5 v5.1.0
github.com/gorilla/websocket v1.5.3
github.com/kardianos/service v1.3.0
github.com/pion/ice/v4 v4.4.4
ddiff --git a/go.sum b/go.sum
iindex f983a81..48542f1 100644
---- a/go.sum
++++ b/go.sum
@@@ -1,3 +1,7 @@
++fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
++fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
++github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
++github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
ddiff --git a/install/install.sh b/install/install.sh
iindex d982e3e..8156b23 100755
---- a/install/install.sh
++++ b/install/install.sh
@@@ -480,6 +480,16 @@ fi
if [ -n "$COMPUTER" ]; then
write_computer_config "$CONF/config.json" "$COMPUTER" "$COMPUTER_HOME"
say "the computer plugin is on at level $COMPUTER for $SVC_USER (folder $COMPUTER_HOME); change it in $CONF/config.json"
++ # The tray starts when they sign in: what waits for approval with Approve and Deny, the stop and resume.
++ had_config=0
++ [ -d "$COMPUTER_HOME/.config" ] && had_config=1
++ if XDG_CONFIG_HOME="$COMPUTER_HOME/.config" "$BIN_DIR/openvibe-node" tray --autostart on >/dev/null 2>&1; then
++ if [ "$(id -u)" = 0 ]; then
++ [ "$had_config" = 1 ] || chown "$SVC_USER": "$COMPUTER_HOME/.config" 2>/dev/null || true
++ chown "$SVC_USER": "$COMPUTER_HOME/.config/autostart" "$COMPUTER_HOME/.config/autostart/openvibe-node-tray.desktop" 2>/dev/null || true
++ fi
++ say "the tray starts when $SVC_USER signs in (openvibe-node tray; openvibe-node tray --autostart off removes it)"
++ fi
fi
# ---- the job worker (--worker): the job user, the egress tools, the Codes job runner, the worker config ----
ddiff --git a/internal/localctl/localctl.go b/internal/localctl/localctl.go
iindex 8256d8b..0913fb0 100644
---- a/internal/localctl/localctl.go
++++ b/internal/localctl/localctl.go
@@@ -1,5 +1,5 @@
// Package localctl is the running Node's local control socket: `openvibe-node stop`, `resume`, `status`, `plugins`,
--// `approvals`, `approve` and `deny` talk to it. It is a Unix domain socket (Windows 10+ supports them too) in the state directory with mode
++// `approvals`, `approve`, `deny` and `tray` talk to it. It is a Unix domain socket (Windows 10+ supports them too) in the state directory with mode
// 0600, so only the account running the Node and root can use it. One JSON request per connection, one JSON answer.
package localctl
@@@ -23,11 +23,15 @@ const (
CmdApprovals = "approvals"
CmdApprove = "approve"
CmdDeny = "deny"
++ // CmdTray: `openvibe-node tray` shows the approvals (On) or has quit; the computer plugin's own desktop prompts
++ // stay quiet while it does.
++ CmdTray = "tray"
)
type Request struct {
Cmd string `json:"cmd"`
ID string `json:"id,omitempty"` // approve and deny: the command id the plugin asked about
++ On bool `json:"on,omitempty"` // tray: the tray is running
}
type Response struct {
ddiff --git a/internal/node/computer_test.go b/internal/node/computer_test.go
iindex 9e6f420..9479899 100644
---- a/internal/node/computer_test.go
++++ b/internal/node/computer_test.go
@@@ -207,6 +207,93 @@ func TestComputerAskWaitsForTheOwnerOnTheMachine(t *testing.T) {
}
}
++// An approval is refused while the agent drives the mouse or keyboard and just after: an approved click must not be
++// able to press another request's Approve button (in the tray or a notification). Deny always goes through.
++func TestComputerApproveWaitsOutTheMouse(t *testing.T) {
++ e, _ := startComputer(t, "ask")
++ b, _ := json.Marshal(map[string]any{"argv": []string{"echo", "later"}})
++ if err := e.conn.Command(protocol.Command{ID: "g1", Kind: protocol.KindExec, Value: b}, 20000); err != nil {
++ t.Fatal(err)
++ }
++ deadline := time.Now().Add(10 * time.Second)
++ for time.Now().Before(deadline) && len(e.node.Approvals()) == 0 {
++ time.Sleep(50 * time.Millisecond)
++ }
++ e.node.mu.Lock()
++ e.node.inputEnded = time.Now() // a click just ran
++ e.node.mu.Unlock()
++ r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "g1"}, 2*time.Second)
++ if err == nil || r.OK || !strings.Contains(r.Error, "mouse or keyboard") {
++ t.Fatalf("approve right after a click: %+v %v", r, err)
++ }
++ if len(e.node.Approvals()) != 1 {
++ t.Fatal("the refused approval no longer waits")
++ }
++ e.node.mu.Lock()
++ e.node.inputs["i1"] = true // an input command in flight, not waiting for approval: it is running
++ e.node.inputEnded = time.Now().Add(-InputGuard - time.Second)
++ e.node.mu.Unlock()
++ if r, _ := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "g1"}, 2*time.Second); r.OK {
++ t.Fatal("approved while an input command runs")
++ }
++ e.node.mu.Lock()
++ delete(e.node.inputs, "i1")
++ e.node.mu.Unlock()
++ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "g1"}, 2*time.Second); err != nil || !r.OK {
++ t.Fatal(err, r.Error)
++ }
++ if d := dataOf(t, func() protocol.Message {
++ m, err := e.conn.Reply("g1", 10*time.Second)
++ if err != nil {
++ t.Fatal(err)
++ }
++ return m
++ }()); d["stdout"] != "later\n" {
++ t.Fatalf("approved exec: %v", d)
++ }
++ if err := e.conn.Command(protocol.Command{ID: "g2", Kind: protocol.KindExec, Value: b}, 20000); err != nil {
++ t.Fatal(err)
++ }
++ for time.Now().Before(deadline.Add(5*time.Second)) && len(e.node.Approvals()) == 0 {
++ time.Sleep(50 * time.Millisecond)
++ }
++ e.node.mu.Lock()
++ e.node.inputEnded = time.Now()
++ e.node.mu.Unlock()
++ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdDeny, ID: "g2"}, 2*time.Second); err != nil || !r.OK {
++ t.Fatal("deny is never held back:", err, r.Error)
++ }
++}
++
++// The tray says it is there on every poll; the core tells the computer plugin at most every trayRetell, and at once
++// when the tray quits.
++func TestComputerTrayPresenceReachesThePlugin(t *testing.T) {
++ e, _ := startComputer(t, "ask")
++ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdTray, On: true}, 2*time.Second); err != nil || !r.OK {
++ t.Fatal(err, r.Error)
++ }
++ e.node.mu.Lock()
++ first := e.node.trayToldAt
++ e.node.mu.Unlock()
++ if first.IsZero() {
++ t.Fatal("the tray's presence was not passed on")
++ }
++ _, _ = localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdTray, On: true}, 2*time.Second)
++ e.node.mu.Lock()
++ again := e.node.trayToldAt
++ e.node.mu.Unlock()
++ if !again.Equal(first) {
++ t.Fatal("told again within trayRetell")
++ }
++ _, _ = localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdTray}, 2*time.Second)
++ e.node.mu.Lock()
++ off := e.node.trayToldAt
++ e.node.mu.Unlock()
++ if !off.IsZero() {
++ t.Fatal("the tray quitting was not passed on")
++ }
++}
++
func TestComputerLocalStopLeavesObserveOnly(t *testing.T) {
e, work := startComputer(t, "trusted")
if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStop}, 2*time.Second); err != nil || !r.OK {
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 0e2d93c..0ba716c 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -90,8 +90,23 @@ type Node struct {
// approvals waiting on the machine (the computer plugin at level "ask"), by command id: what
// `openvibe-node approvals` lists and `approve`/`deny` answer.
approvals map[string]Approval
++ // input commands in flight, by id, and when the last one finished: `approve` is refused while the agent drives
++ // the mouse or keyboard and for InputGuard after, so an approved click cannot press another request's Approve.
++ inputs map[string]bool
++ inputEnded time.Time
++ trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals
}
++// InputGuard is how long after an input command `approve` stays refused.
++const InputGuard = 2 * time.Second
++
++// trayTTL is how long the computer plugin trusts one "the tray shows your approvals"; the tray says it again
++// every poll and the core passes it on at most every trayRetell.
++const (
++ trayTTL = 15
++ trayRetell = 5 * time.Second
++)
++
// Approval is one computer command waiting for its owner's answer on the machine.
type Approval struct {
ID string `json:"id"`
@@@ -115,7 +130,7 @@ func New(opt Options) (*Node, error) {
opt.Log.Error("latch", "err", err)
}
n := &Node{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),
-- jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}}
++ jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}, inputs: map[string]bool{}}
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
var specs []plugins.Spec
@@@ -506,9 +521,22 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, r
nack(protocol.FaultUnsupported, "no driver on this device handles "+c.Kind+" (the computer plugin is off)")
return
}
++ if c.Kind == protocol.KindInput {
++ n.mu.Lock()
++ n.inputs[c.ID] = true
++ n.mu.Unlock()
++ }
wait := p.Begin(n.ctx, c.ID, c.Kind, c.Value, int(window))
go func() {
r := wait()
++ if c.Kind == protocol.KindInput {
++ n.mu.Lock()
++ delete(n.inputs, c.ID)
++ if r.OK || (r.FaultCode != protocol.FaultNotAllowed && r.FaultCode != protocol.FaultLocalStop) {
++ n.inputEnded = time.Now() // it ran (or may have run part way)
++ }
++ n.mu.Unlock()
++ }
var msg protocol.Message
switch {
case r.OK && len(r.Data) > protocol.MaxAckData:
@@@ -986,6 +1014,42 @@ func (n *Node) Approvals() []Approval {
return out
}
++// drivingLocked: an input command runs (it is in flight and not waiting for approval) or finished under InputGuard ago.
++func (n *Node) drivingLocked() bool {
++ for id := range n.inputs {
++ if _, waiting := n.approvals[id]; !waiting {
++ return true
++ }
++ }
++ return !n.inputEnded.IsZero() && time.Since(n.inputEnded) < InputGuard
++}
++
++// tellTray passes the tray's presence to the plugins that handle computer kinds: at once when it quits, at most every
++// trayRetell while it runs.
++func (n *Node) tellTray(on bool) {
++ n.mu.Lock()
++ if on && !n.trayToldAt.IsZero() && time.Since(n.trayToldAt) < trayRetell {
++ n.mu.Unlock()
++ return
++ }
++ if on {
++ n.trayToldAt = time.Now()
++ } else {
++ n.trayToldAt = time.Time{}
++ }
++ n.mu.Unlock()
++ if n.mgr == nil {
++ return
++ }
++ seen := map[*plugins.Plugin]bool{}
++ for kind := range protocol.ComputerKinds {
++ if p := n.mgr.Route(kind, "", ""); p != nil && !seen[p] {
++ seen[p] = true
++ _ = p.Tray(on, trayTTL)
++ }
++ }
++}
++
func (n *Node) control(r localctl.Request) localctl.Response {
switch r.Cmd {
case localctl.CmdApprovals:
@@@ -997,6 +1061,10 @@ func (n *Node) control(r localctl.Request) localctl.Response {
case localctl.CmdApprove, localctl.CmdDeny:
n.mu.Lock()
a, ok := n.approvals[r.ID]
++ if ok && r.Cmd == localctl.CmdApprove && n.drivingLocked() {
++ n.mu.Unlock()
++ return localctl.Response{Error: "the agent is using the mouse or keyboard (or just did): answer again in a moment, so that its click cannot approve this"}
++ }
if ok {
delete(n.approvals, r.ID)
}
@@@ -1012,6 +1080,9 @@ func (n *Node) control(r localctl.Request) localctl.Response {
return localctl.Response{Error: err.Error()}
}
return localctl.Response{OK: true}
++ case localctl.CmdTray:
++ n.tellTray(r.On)
++ return localctl.Response{OK: true}
case localctl.CmdStop:
n.mgr.EstopAll() // at once, then persist
if _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {
ddiff --git a/internal/plugins/plugin.go b/internal/plugins/plugin.go
iindex da60582..7f39de4 100644
---- a/internal/plugins/plugin.go
++++ b/internal/plugins/plugin.go
@@@ -206,6 +206,11 @@ func (p *Plugin) Answer(id string, approved bool) error {
return p.send(map[string]any{"op": "approval", "id": id, "approved": approved})
}
++// Tray tells the plugin whether the Node's tray shows its approvals, for the next ttlS seconds unless told again.
++func (p *Plugin) Tray(on bool, ttlS int) error {
++ return p.send(map[string]any{"op": "tray", "attached": on, "ttl_s": ttlS})
++}
++
// Command sends a command and waits for the plugin's ack or nack.
func (p *Plugin) Command(ctx context.Context, id, kind string, value json.RawMessage, deadlineMS int) Reply {
return p.Begin(ctx, id, kind, value, deadlineMS)()
ddiff --git a/plugins/computer/openvibe_computer/__init__.py b/plugins/computer/openvibe_computer/__init__.py
iindex 1e3d8e2..5d7f1a6 100644
---- a/plugins/computer/openvibe_computer/__init__.py
++++ b/plugins/computer/openvibe_computer/__init__.py
@@@ -6,7 +6,8 @@ command can never raise it:
* ``observe`` (the default): capture the screen; list, stat and read files inside the roots.
* ``ask``: also run programs, write files inside the roots and use the mouse and keyboard, each only after the owner
approves it on the machine
-- (``openvibe-node approvals``, ``approve <id>``, ``deny <id>``); unanswered requests expire.
++ (the tray, a notification's buttons, or ``openvibe-node approvals``, ``approve <id>``, ``deny <id>``); unanswered
++ requests expire.
* ``trusted``: run programs, write files inside the roots and use the mouse and keyboard without asking.
* ``full``: as trusted, anywhere the account running the Node can reach.
@@@ -40,6 +41,7 @@ Command kinds and their answers (the ack's ``data``):
"""
import base64
++import contextlib
import http.client
import ipaddress
import os
@@@ -47,6 +49,7 @@ import socket
import ssl
import shlex
import shutil
++import signal
import stat
import subprocess
import sys
@@@ -69,6 +72,9 @@ MAX_STDIN = 64 * 1024
MAX_LIST = 1000
MAX_RUNNING = 2
MAX_WAITING = 8
++# An approval from the desktop (the notification's button or the macOS dialog) is not taken while the agent drives the
++# mouse or keyboard, nor this many seconds after: an approved click must not land on another request's Approve button.
++INPUT_GUARD_S = 2.0
# The core caps an ack's data at 512 KB of JSON; base64 grows by a third.
MAX_OUTPUT_CAP = 192 * 1024
MAX_READ_CAP = 256 * 1024
@@@ -90,6 +96,21 @@ CLICLICK_KEYS = {"return": "return", "enter": "enter", "escape": "esc", "esc": "
"page_down": "page-down", "next": "page-down"}
++
++def _parse_notify_send(out):
++ """notify-send --wait prints the name of the button pressed; nothing when the notification just closed."""
++ lines = (out or "").strip().splitlines()
++ last = lines[-1].strip() if lines else ""
++ return True if last == "approve" else False if last == "deny" else None
++
++
++def _parse_dialog(out):
++ """osascript's display dialog: "button returned:Approve, gave up:false"; giving up answers nothing."""
++ out = out or ""
++ if "gave up:true" in out:
++ return None
++ return True if "button returned:Approve" in out else False if "button returned:Deny" in out else None
++
def _int(v, name, lo, hi, default):
if v is None:
return default
@@@ -121,6 +142,11 @@ class Computer(Plugin):
self.allow_probe = True
self.allow_input = True
self.notify = True
++ self.notify_actions = None # whether notify-send shows buttons (libnotify 0.7.10+); found out on first use
++ self.prompts = {} # command id -> the notification or dialog process asking about it
++ self.tray_until = 0.0 # the Node's tray shows the approvals until then (op "tray")
++ self.input_busy = 0
++ self.input_ended = None
self.approval_timeout_s = 120
self.pending = {} # command id -> (run, expires_at, summary)
self.procs = set()
@@@ -206,6 +232,7 @@ class Computer(Plugin):
self._kill(p)
self._close_browser()
for cid in waiting:
++ self._close_prompt(cid)
self.emit_event("approval_done", id=cid, approved=False, reason="stopped")
self.fail(cid, "local_stop", "stopped on the machine before it ran")
@@@ -231,6 +258,7 @@ class Computer(Plugin):
del self.pending[cid]
waiting, running = len(self.pending), self.running
for cid in expired:
++ self._close_prompt(cid)
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.
@@@ -288,7 +316,7 @@ class Computer(Plugin):
if not tool:
raise Fault("unsupported", "no input tool on this machine (xdotool on X11, cliclick on macOS)")
run, summary = self._input_op(tool, v)
-- return self._act(cmd, level, run, summary, threaded=True)
++ return self._act(cmd, level, self._driving(run), summary, threaded=True)
if cmd.kind == "browser":
run, summary, acting = self._browser_op(v, level)
if acting:
@@@ -320,29 +348,124 @@ class Computer(Plugin):
return self._start(cmd, run) if threaded else run()
def _notify(self, cid, summary):
-- """A desktop notification that a request waits (config `notify`, on by default), when the Node runs in a
-- graphical session: notify-send on Linux, osascript on macOS. Best effort: it never blocks or fails a command."""
-- if not self.notify:
++ """Tell the desktop that a request waits (config `notify`, on by default) when the Node runs in a graphical
++ session and the Node's tray does not show it already (`openvibe-node tray`). On Linux the notification has
++ Approve and Deny buttons when notify-send can show them (libnotify 0.7.10+); on macOS a dialog asks, with Deny
++ as its default button. Best effort: it never blocks or fails a command."""
++ if not self.notify or self.clock() < self.tray_until:
return
title = "OpenVibe: your agent asks"
-- body = "%s\nopenvibe-node approve %s (or deny %s)" % (summary[:200], cid, cid)
++ hint = "openvibe-node approve %s (or deny %s)" % (cid, cid)
++ parse = 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]
++ if self._notify_can_act():
++ argv = ["notify-send", "--app-name=OpenVibe Node", "--urgency=critical", "--wait",
++ "--expire-time=%d" % (self.approval_timeout_s * 1000), "--action=approve=Approve",
++ "--action=deny=Deny", "--", title, summary[:200]]
++ parse = _parse_notify_send
++ else:
++ argv = ["notify-send", "--app-name=OpenVibe Node", title, "%s\n%s" % (summary[:200], hint)]
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))]
++ argv = ["osascript", "-e", 'display dialog %s with title %s buttons {"Deny", "Approve"} default button "Deny" '
++ "giving up after %d with icon caution" % (quote(summary[:400]), quote(title), self.approval_timeout_s)]
++ parse = _parse_dialog
else:
return
try:
-- self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
++ if parse is None:
++ self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
++ return
++ proc = self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
++ start_new_session=True, text=True)
except OSError:
++ return
++ with self.mu:
++ old = self.prompts.pop(cid, None)
++ self.prompts[cid] = proc
++ if old is not None:
++ self._end_prompt(old)
++ threading.Thread(target=self._await_prompt, args=(cid, proc, parse), name="computer-ask-%s" % cid, daemon=True).start()
++
++ def _notify_can_act(self):
++ """Whether this notify-send has --action and --wait (libnotify 0.7.10+), asked once."""
++ if self.notify_actions is None:
++ try:
++ p = self.popen(["notify-send", "--help"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
++ stderr=subprocess.STDOUT, text=True)
++ out = p.communicate(timeout=3)[0] or ""
++ self.notify_actions = "--action" in out and "--wait" in out
++ except (OSError, subprocess.SubprocessError, AttributeError):
++ self.notify_actions = False
++ return self.notify_actions
++
++ def _await_prompt(self, cid, proc, parse):
++ try:
++ out = proc.communicate()[0] or ""
++ except (OSError, ValueError, AttributeError):
++ out = ""
++ with self.mu:
++ if self.prompts.get(cid) is proc:
++ del self.prompts[cid]
++ answer = parse(out)
++ if answer is not None:
++ self._desktop_answer(cid, answer)
++
++ def _desktop_answer(self, cid, approved):
++ """The owner pressed Approve or Deny on the desktop. An approval that arrives while the agent drives the mouse or
++ keyboard (or just after) may be the agent's own click: it is not taken, and the request is shown again."""
++ with self.mu:
++ item = self.pending.get(cid)
++ driving = self.input_busy > 0 or (self.input_ended is not None and self.clock() - self.input_ended < INPUT_GUARD_S)
++ if item is None:
++ return
++ if approved and driving:
++ self.log("an approval of %s came from the desktop while the agent drove the mouse or keyboard: asking again", cid)
++ self._notify(cid, item[2])
++ return
++ with (self.runtime.lock if self.runtime is not None else contextlib.nullcontext()):
++ self.on_approval(cid, approved)
++
++ def _close_prompt(self, cid):
++ """Take down the notification or dialog of a request answered elsewhere, expired or stopped."""
++ with self.mu:
++ proc = self.prompts.pop(cid, None)
++ if proc is not None:
++ self._end_prompt(proc)
++
++ def _end_prompt(self, proc):
++ try:
++ if proc.poll() is None:
++ proc.send_signal(signal.SIGINT) # notify-send --wait closes its notification on SIGINT
++ except (OSError, AttributeError):
pass
++ def _driving(self, run):
++ """An input command, counted while it runs so desktop approvals wait it out (INPUT_GUARD_S)."""
++ def go():
++ with self.mu:
++ self.input_busy += 1
++ try:
++ return run()
++ finally:
++ with self.mu:
++ self.input_busy -= 1
++ self.input_ended = self.clock()
++ return go
++
++ def on_tray(self, attached, ttl_s):
++ try:
++ ttl = max(1.0, min(float(ttl_s or 15), 60.0))
++ except (TypeError, ValueError):
++ ttl = 15.0
++ self.tray_until = self.clock() + ttl if attached else 0.0
++
def on_approval(self, cid, approved):
with self.mu:
item = self.pending.pop(cid, None)
if item is None:
return
++ self._close_prompt(cid)
(run, threaded), _, summary = item
self.emit_event("approval_done", id=cid, approved=bool(approved))
if not approved:
ddiff --git a/plugins/computer/tests/test_computer.py b/plugins/computer/tests/test_computer.py
iindex 2a81d67..1588ab0 100644
---- a/plugins/computer/tests/test_computer.py
++++ b/plugins/computer/tests/test_computer.py
@@@ -46,6 +46,31 @@ class Out(io.StringIO):
self.cond.wait(left)
++class FakePrompt:
++ """A notify-send --wait or osascript dialog process: answer() is the owner pressing a button."""
++
++ def __init__(self, argv):
++ self.argv = argv
++ self.out = ""
++ self.done = threading.Event()
++ self.closed = threading.Event()
++
++ def answer(self, out):
++ self.out = out
++ self.done.set()
++
++ def communicate(self, timeout=None):
++ self.done.wait(10)
++ return (self.out, None)
++
++ def poll(self):
++ return 0 if self.done.is_set() else None
++
++ def send_signal(self, sig):
++ self.closed.set()
++ self.done.set()
++
++
def reply(out, cid):
return out.wait_for(lambda m: m.get("id") == cid and m.get("op") in ("ack", "nack"))
@@@ -173,6 +198,7 @@ class ComputerTest(unittest.TestCase):
self.plugin.popen = lambda argv, **kw: calls.append(argv) if argv[0] in ("notify-send", "osascript") else real(argv, **kw)
self.plugin.which = lambda name: "/usr/bin/" + name
self.plugin.env = {"DISPLAY": ":0"}
++ self.plugin.notify_actions = False # an older notify-send: no buttons, the command to answer with
cid = self.cmd("exec", {"argv": ["echo", "hi"]})
self.out.wait_for(lambda m: m.get("name") == "approval")
self.assertEqual(calls[0][:3], ["notify-send", "--app-name=OpenVibe Node", "OpenVibe: your agent asks"])
@@@ -185,6 +211,94 @@ class ComputerTest(unittest.TestCase):
self.cmd("exec", {"argv": ["echo", "quiet"]})
self.assertEqual(len(calls), 1, "notify off")
++ def desktop(self, platform="linux"):
++ """A graphical session whose notification or dialog the test answers: returns the prompts shown."""
++ prompts = []
++ real = self.plugin.popen
++
++ def popen(argv, **kw):
++ if argv[0] in ("notify-send", "osascript"):
++ p = FakePrompt(argv)
++ prompts.append(p)
++ return p
++ return real(argv, **kw)
++ self.plugin.popen = popen
++ self.plugin.which = lambda name: "/usr/bin/" + name
++ self.plugin.platform = platform
++ self.plugin.env = {"DISPLAY": ":0"}
++ self.plugin.notify_actions = True
++ return prompts
++
++ def test_the_notification_approves_and_denies(self):
++ self.boot("ask")
++ prompts = self.desktop()
++ cid = self.cmd("exec", {"argv": ["echo", "from the desktop"]})
++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
++ argv = prompts[0].argv
++ self.assertIn("--action=approve=Approve", argv)
++ self.assertIn("--wait", argv)
++ self.assertEqual(argv[-3:-1], ["--", "OpenVibe: your agent asks"])
++ prompts[0].answer("approve\n")
++ self.assertEqual(reply(self.out, cid)["data"]["stdout"], "from the desktop\n")
++ cid = self.cmd("fs", {"op": "write", "path": os.path.join(self.dir, "no.txt"), "content_b64": "eA=="})
++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
++ prompts[1].answer("deny\n")
++ self.assertEqual(reply(self.out, cid)["fault_code"], "not_allowed")
++ self.assertFalse(os.path.exists(os.path.join(self.dir, "no.txt")))
++
++ def test_an_answer_elsewhere_takes_the_notification_down(self):
++ self.boot("ask")
++ prompts = self.desktop()
++ cid = self.cmd("exec", {"argv": ["echo", "cli"]})
++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
++ self.rt.dispatch({"op": "approval", "id": cid, "approved": True})
++ reply(self.out, cid)
++ self.assertTrue(prompts[0].closed.wait(5), "the notification is closed once the CLI answered")
++
++ def test_a_desktop_approval_while_the_agent_drives_the_mouse_is_not_taken(self):
++ self.boot("ask")
++ prompts = self.desktop()
++ cid = self.cmd("exec", {"argv": ["echo", "maybe"]})
++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
++ t = [time.monotonic()] # the real clock's value: the command's own deadline counts on it
++ self.plugin.clock = lambda: t[0]
++ self.plugin.input_ended = t[0] - 0.5 # a click ran half a second ago
++ prompts[0].answer("approve\n")
++ deadline = time.monotonic() + 5
++ while len(prompts) < 2 and time.monotonic() < deadline:
++ time.sleep(0.01)
++ self.assertEqual(len(prompts), 2, "asked again")
++ self.assertIn(cid, self.plugin.pending, "still waiting")
++ t[0] += 3
++ prompts[1].answer("approve\n")
++ self.assertEqual(reply(self.out, cid)["data"]["stdout"], "maybe\n")
++
++ def test_the_tray_shows_approvals_instead(self):
++ self.boot("ask")
++ prompts = self.desktop()
++ self.rt.dispatch({"op": "tray", "attached": True, "ttl_s": 15})
++ cid = self.cmd("exec", {"argv": ["echo", "tray"]})
++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
++ self.assertEqual(prompts, [], "the tray notifies; the plugin stays quiet")
++ self.rt.dispatch({"op": "tray", "attached": False})
++ self.cmd("exec", {"argv": ["echo", "no tray"]})
++ self.assertEqual(len(prompts), 1)
++
++ def test_macos_asks_with_a_dialog(self):
++ self.boot("ask")
++ prompts = self.desktop("darwin")
++ cid = self.cmd("exec", {"argv": ["echo", "mac"]})
++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
++ script = prompts[0].argv[2]
++ self.assertIn('buttons {"Deny", "Approve"} default button "Deny"', script)
++ prompts[0].answer("button returned:Approve, gave up:false\n")
++ self.assertEqual(reply(self.out, cid)["data"]["stdout"], "mac\n")
++ cid = self.cmd("exec", {"argv": ["echo", "ignored"]})
++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
++ prompts[1].answer("button returned:, gave up:true\n")
++ time.sleep(0.05)
++ self.assertIn(cid, self.plugin.pending, "giving up answers nothing: the request waits until it expires")
++
def test_unanswered_requests_expire(self):
self.boot("ask", approval_timeout_s=10)
t = [1000.0]
ddiff --git a/plugins/sdk/openvibe_plugin/__init__.py b/plugins/sdk/openvibe_plugin/__init__.py
iindex 962ba03..7954a02 100644
---- a/plugins/sdk/openvibe_plugin/__init__.py
++++ b/plugins/sdk/openvibe_plugin/__init__.py
@@@ -130,6 +130,11 @@ class Plugin:
"""The owner answered, on the machine (`openvibe-node approve|deny <id>`), an approval this plugin asked for
with emit_event("approval", id=…). Runs on the runtime's main thread."""
++ def on_tray(self, attached, ttl_s):
++ """The Node's tray (`openvibe-node tray`, in the owner's graphical session) is showing this plugin's approvals,
++ for the next ttl_s seconds unless told again. A plugin that notifies the desktop itself can stay quiet then.
++ Sent only to the plugin that handles computer kinds."""
++
# Helpers for drivers.
def complete(self, cid, data=None):
"""Answer a deferred command with ack (and its data). Safe from any thread."""
@@@ -276,6 +281,11 @@ class Runtime:
self.plugin.on_approval(msg.get("id"), msg.get("approved") is True)
except Exception as e:
self.plugin.log("approval handler failed: %r", e)
++ elif op == "tray":
++ try:
++ self.plugin.on_tray(msg.get("attached") is True, msg.get("ttl_s"))
++ except Exception as e:
++ self.plugin.log("tray handler failed: %r", e)
elif op == "resume":
if self.local_stop_pending:
self.plugin.log("resume ignored: the local stop has not reached the core yet")
ddiff --git a/plugins/sdk/tests/test_runtime.py b/plugins/sdk/tests/test_runtime.py
iindex f7f03fd..23ec449 100644
---- a/plugins/sdk/tests/test_runtime.py
++++ b/plugins/sdk/tests/test_runtime.py
@@@ -284,6 +284,9 @@ class Later(Plugin):
else:
self.fail(cid, "not_allowed", "denied")
++ def on_tray(self, attached, ttl_s):
++ self.answers.append(("tray", attached, ttl_s))
++
class DeferredTest(unittest.TestCase):
def test_data_deferred_and_approval(self):
@@@ -303,6 +306,16 @@ class DeferredTest(unittest.TestCase):
self.assertEqual(p.answers[-1], ("c", False), "only a JSON true approves")
self.assertEqual(lines(out)[-1], {"op": "nack", "id": "c", "fault_code": "not_allowed", "message": "denied"})
++ def test_the_tray_op_reaches_the_plugin(self):
++ rt, p, out, clock = make(Later())
++ rt.dispatch({"op": "hello", "config": {}})
++ rt.dispatch({"op": "tray", "attached": True, "ttl_s": 15})
++ rt.dispatch({"op": "tray", "attached": "yes"})
++ self.assertEqual(p.answers, [("tray", True, 15), ("tray", False, None)], "only a JSON true attaches")
++ rt2, p2, out2, _ = make()
++ rt2.dispatch({"op": "hello", "config": {}})
++ rt2.dispatch({"op": "tray", "attached": True, "ttl_s": 15}) # a plugin without on_tray: the base no-op
++
def test_motion_kinds_keep_the_short_cap(self):
rt, p, out, clock = make()
rt.dispatch({"op": "hello", "config": {}})