openvibers/openvibe.link Commits

Ask a clone approval on the desktop too, not only in the terminal (#153)

OP OpenVibers committed 2 days ago · 627971c63b · 6 files, +564 −19

A "Clone to my computer" request now shows a desktop prompt with Approve
and Deny (a notification with buttons on Linux, a dialog with Deny default
on macOS) next to the terminal's y/N: the first answer wins, and the prompt
is taken down once the terminal answers. Only an explicit approval approves;
a dismissal, a closed input and anything but y are no, an expired request
asks nothing, and a piped input never decides while the desktop asks. The
non-empty folder's second answer can come from the desktop as well.

Version 0.55.0.

Checked: go vet and go test -race ./cmd/openvibe-link ./internal/gitrelay
(new git_prompt_test.go: desktop Approve/Deny, no-desktop fallback to the
terminal, expiry asks nothing, no-terminal-no-desktop errors, desktop alone
decides, cancelled run stops, terminal answer takes the desktop down,
overwrite second answer, osascript quoting); GOOS=windows/darwin builds.

Changes

ddiff --git a/README.md b/README.md
iindex 55d6a9f..1fded87 100644
---- a/README.md
++++ b/README.md
@@@ -138,8 +138,9 @@ outside the root, or a non-empty folder without a second answer. One clone runs
`openvibe-link://clone?url=…` deep link is the same request. `openvibe-link git connect '<link>'` publishes the
repositories under `--root` to OpenVibe Git, read-only, until Ctrl-C: the page lists them and browses tree, file and
log through the poll the Link opened. The same poll also carries a **"Clone to <this computer>"** from the repository
--page: the terminal shows the repository, who asked, the folder and the time left, and nothing is written until you
--answer `y` (`--yes` answers for a script). Neither command opens a port on this computer; both connect out to Git.
++page: Link asks on this computer — a desktop prompt with Approve and Deny (a notification on Linux, a dialog on
++macOS) and the same question with `y`/`N` in the terminal, whichever answers first — and nothing is written until
++you approve (`--yes` answers for a script). Neither command opens a port on this computer; both connect out to Git.
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md),
ddiff --git a/STATUS.json b/STATUS.json
iindex ce596d7..57507c6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,8 +5,8 @@
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
-- "version": "0.53.0",
-- "release": "v0.53.0: clone to this computer from the page (Git plan G3, step 3). The repository page's Get it panel lists the person's own Link computers and sends the clone down the watch the chosen one already holds: Link shows the repository, who asked, the folder and the time left, and writes nothing until `y` at the terminal (`--yes` for a script). The same checks as `openvibe-link clone`: the root the person chose, no path out of it, a non-empty folder needing a second answer, one clone at a time, two minutes, progress with git's percentage, done with the folder, refused and failed with git's last line.",
++ "version": "0.55.0",
++ "release": "v0.55.0: a clone approval asks on the desktop too (Git plan G3, step 3). The repository page's clone request shows the repository, who asked, the folder and the time left in a desktop prompt with Approve and Deny (a notification on Linux, a dialog on macOS) as well as the terminal's y/N, whichever answers first; nothing is written until approved (`--yes` for a script).",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"deployed": false,
ddiff --git a/cmd/openvibe-link/git.go b/cmd/openvibe-link/git.go
iindex 3de87d5..b16aaa1 100644
---- a/cmd/openvibe-link/git.go
++++ b/cmd/openvibe-link/git.go
@@@ -17,7 +17,8 @@ import (
)
// cmdClone serves one "Clone to my computer" link from a repository page. The person sees the repository and the
--// folder here and answers; git runs with their own git, inside the root they chose, and the page shows progress.
++// folder on the desktop and here, and answers either; git runs with their own git, inside the root they chose,
++// and the page shows progress.
func cmdClone(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io.Writer) error {
if root == "" {
home, err := os.UserHomeDir()
@@@ -28,7 +29,7 @@ func cmdClone(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
-- approve, overwrite := cloneApprover(root, yes, stdin, stdout)
++ approve, overwrite := cloneApproverWithDesktop(root, yes, stdin, stdout, realDesktop{})
res, err := gitrelay.Clone(ctx, raw, gitrelay.CloneOptions{Root: root, Approve: approve, ApproveOverwrite: overwrite})
if err != nil {
return err
@@@ -48,39 +49,138 @@ func cloneApprover(root string, yes bool, stdin io.Reader, stdout io.Writer) (
func(context.Context, gitrelay.CloneRequest) (gitrelay.CloneDecision, error),
func(context.Context, gitrelay.CloneRequest, string) (bool, error),
) {
-- approve := func(_ context.Context, req gitrelay.CloneRequest) (gitrelay.CloneDecision, error) {
++ return cloneApproverWithDesktop(root, yes, stdin, stdout, nil)
++}
++
++// cloneApproverWithDesktop asks the same question on the desktop when desktop is
++// set (a prompt with Approve and Deny) and in the terminal: the first answer
++// wins, and the desktop prompt is taken down once the terminal answers. With a
++// nil desktop only the terminal asks, as before.
++func cloneApproverWithDesktop(root string, yes bool, stdin io.Reader, stdout io.Writer, desktop desktopPrompter) (
++ func(context.Context, gitrelay.CloneRequest) (gitrelay.CloneDecision, error),
++ func(context.Context, gitrelay.CloneRequest, string) (bool, error),
++) {
++ approve := func(ctx context.Context, req gitrelay.CloneRequest) (gitrelay.CloneDecision, error) {
_, name, err := gitrelay.RepoName(req.Repo)
if err != nil {
return gitrelay.CloneDecision{}, err
}
folder := filepath.Join(root, name)
-- if !req.ExpiresAt.IsZero() {
-- left := time.Until(req.ExpiresAt).Round(time.Second)
-- if left > 0 {
-- fmt.Fprintf(stdout, "\nOpenVibe Git: clone %s\n into %s\n asked by %s, %s left\n", req.Repo, folder, orSomeone(req.RequestedBy), left)
-- }
++ left := time.Until(req.ExpiresAt).Round(time.Second)
++ if !req.ExpiresAt.IsZero() && left > 0 {
++ fmt.Fprintf(stdout, "\nOpenVibe Git: clone %s\n into %s\n asked by %s, %s left\n", req.Repo, folder, orSomeone(req.RequestedBy), left)
} else {
fmt.Fprintf(stdout, "\nOpenVibe Git: clone %s\n into %s\n", req.Repo, folder)
}
if yes {
return gitrelay.CloneDecision{Approve: true, Folder: folder}, nil
}
-- ok, err := confirm(stdin, stdout, "Clone it here?")
++ ok, err := askPerson(ctx, stdin, stdout, desktop, "Clone it here?",
++ "OpenVibe: clone "+req.Repo+"?",
++ "Clone "+req.Repo+" into "+folder+"\nAsked by "+orSomeone(req.RequestedBy)+cloneLeft(req.ExpiresAt),
++ req.ExpiresAt)
if err != nil {
return gitrelay.CloneDecision{}, err
}
return gitrelay.CloneDecision{Approve: ok, Folder: folder}, nil
}
-- overwrite := func(_ context.Context, req gitrelay.CloneRequest, folder string) (bool, error) {
++ overwrite := func(ctx context.Context, req gitrelay.CloneRequest, folder string) (bool, error) {
fmt.Fprintf(stdout, "\n%s already has files in it. Cloning into it adds this repository's files beside them.\n", folder)
if yes {
return true, nil
}
-- return confirm(stdin, stdout, "Clone into it anyway?")
++ return askPerson(ctx, stdin, stdout, desktop, "Clone into it anyway?",
++ "OpenVibe: clone into "+filepath.Base(folder)+"?",
++ folder+" already has files in it. Cloning adds this repository's files beside them.",
++ req.ExpiresAt)
}
return approve, overwrite
}
++// cloneLeft is the desktop body's countdown: ", 1m20s left", or "" without it.
++func cloneLeft(expiresAt time.Time) string {
++ if expiresAt.IsZero() {
++ return ""
++ }
++ if left := time.Until(expiresAt).Round(time.Second); left > 0 {
++ return ", " + left.String() + " left"
++ }
++ return ""
++}
++
++// askPerson takes one yes/no answer from the terminal and the desktop prompt,
++// whichever answers first. Without a desktop it is exactly confirm: a closed
++// input refuses, and a missing one errors. With one, a piped input does not
++// decide (there is no person at it); the desktop does. Past the request's expiry
++// there is nothing to ask anymore, and the answer is an error, not a refusal.
++func askPerson(ctx context.Context, stdin io.Reader, stdout io.Writer, desktop desktopPrompter, question, title, body string, expiresAt time.Time) (bool, error) {
++ if ctx.Err() != nil {
++ return false, ctx.Err()
++ }
++ if desktop == nil {
++ return confirm(stdin, stdout, question)
++ }
++ if !expiresAt.IsZero() && time.Until(expiresAt) <= 0 {
++ return false, errCloneExpired
++ }
++ if !desktop.available() {
++ // No prompt to show: the terminal decides alone, exactly as before.
++ return confirm(stdin, stdout, question)
++ }
++ // A pipe or a file is not the person, so it does not decide when the
++ // desktop asks; a stray pipe cannot refuse for them.
++ useTerminal := stdin != nil && isTerminal(stdin)
++ type verdict struct {
++ approved bool
++ asked bool // this side actually asked; otherwise wait for the other
++ err error
++ }
++ wait := ctx
++ cancel := context.CancelFunc(func() {})
++ if !expiresAt.IsZero() {
++ wait, cancel = context.WithTimeout(ctx, time.Until(expiresAt))
++ }
++ defer cancel()
++ ch := make(chan verdict, 2)
++ if useTerminal {
++ go func() {
++ ok, err := confirm(stdin, stdout, question)
++ ch <- verdict{approved: ok, asked: err == nil, err: err}
++ }()
++ }
++ go func() {
++ ans := desktop.ask(wait, title, body, time.Until(expiresAt))
++ if ans == nil {
++ ch <- verdict{asked: false}
++ return
++ }
++ ch <- verdict{approved: *ans, asked: true}
++ }()
++ pending := 1
++ if useTerminal {
++ pending = 2
++ }
++ for pending > 0 {
++ select {
++ case v := <-ch:
++ if v.err != nil {
++ return false, v.err
++ }
++ if !v.asked {
++ pending--
++ continue
++ }
++ return v.approved, nil
++ case <-wait.Done():
++ if ctx.Err() != nil {
++ return false, ctx.Err()
++ }
++ return false, errCloneExpired
++ }
++ }
++ return false, errNobodyToAsk
++}
++
func orSomeone(who string) string {
if strings.TrimSpace(who) == "" {
return "someone on the page"
@@@ -89,8 +189,8 @@ func orSomeone(who string) string {
}
// cmdGitConnect publishes this computer's repositories to OpenVibe Git, read-only, until Ctrl-C, and lets the person
--// clone one of their repositories here: a page's request waits until it is answered with y here. It needs a link
--// from the person's Git page; the machine only ever polls Git, so nothing is opened on this computer.
++// clone one of their repositories here: a page's request waits until it is answered on the desktop or with y here.
++// It needs a link from the person's Git page; the machine only ever polls Git, so nothing is opened on this computer.
func cmdGitConnect(g *globals, raw, root string, yes bool, stdin io.Reader, stdout io.Writer) error {
if root == "" {
home, err := os.UserHomeDir()
@@@ -102,7 +202,7 @@ func cmdGitConnect(g *globals, raw, root string, yes bool, stdin io.Reader, stdo
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
fmt.Fprintf(stdout, "Publishing the repositories under %s to OpenVibe Git, read-only, until Ctrl-C.\n", root)
-- fmt.Fprintln(stdout, "A clone asked for from your repository page waits here for your answer; nothing is written before it.")
++ fmt.Fprintln(stdout, "A clone asked for from your repository page waits for your answer, on the desktop or here; nothing is written before it.")
approve, overwrite := cloneApprover(root, yes, stdin, stdout)
return gitrelay.MachineConnect(ctx, raw, gitrelay.MachineOptions{
Root: root, Log: g.log, Name: hostName(), Approve: approve, ApproveOverwrite: overwrite,
ddiff --git a/cmd/openvibe-link/git_prompt.go b/cmd/openvibe-link/git_prompt.go
nnew file mode 100644
iindex 0000000..a0b43ff
---- /dev/null
++++ b/cmd/openvibe-link/git_prompt.go
@@@ -0,0 +1,226 @@
++package main
++
++import (
++ "bytes"
++ "context"
++ "errors"
++ "io"
++ "os"
++ "os/exec"
++ "runtime"
++ "strings"
++ "sync"
++ "syscall"
++ "time"
++)
++
++// A clone approval reaches the person two ways: the y/N in the terminal that runs
++// `git connect` (or `clone`), and a prompt on the desktop with Approve and Deny —
++// the same pairing the computer plugin uses when the tray is not showing it
++// (a notification with buttons on Linux, a dialog on macOS). Either answer wins;
++// the prompt is taken down once the person answers anywhere. Only an explicit
++// approval approves: a dismissed prompt, a closed input and anything but y are no.
++
++// errCloneExpired is an approval that waited past the request's two minutes.
++var errCloneExpired = errors.New("the request expired after two minutes; ask the page again")
++
++// errNobodyToAsk is an approval with no terminal to read and no desktop to ask on.
++var errNobodyToAsk = errors.New("there is no terminal to ask on and no desktop prompt here; pass --yes to approve from a script")
++
++// desktopPrompter shows one yes/no question on the desktop: the clone approval
++// with Approve and Deny. available reports whether a prompt could be shown,
++// without showing one. ask shows title and body and reports the person's
++// answer: true is Approve, false is Deny or a dismissal (only an explicit
++// approval approves). A nil answer means the prompt could not be shown after
++// all, so the caller falls back to whatever else can ask. giveUp bounds the
++// prompt: the time left on the request, 0 for none.
++type desktopPrompter interface {
++ available() bool
++ ask(ctx context.Context, title, body string, giveUp time.Duration) *bool
++}
++
++// realDesktop is the desktop half of a clone approval: notify-send with Approve
++// and Deny on Linux, an osascript dialog with Deny default on macOS. Elsewhere
++// it is never available (Windows has the tray; without it the terminal asks).
++type realDesktop struct{}
++
++func (realDesktop) available() bool { return desktopFacility() }
++
++func (realDesktop) ask(ctx context.Context, title, body string, giveUp time.Duration) *bool {
++ switch runtime.GOOS {
++ case "linux":
++ return askNotifySend(ctx, title, body, giveUp)
++ case "darwin":
++ return askDialog(ctx, title, body, giveUp)
++ }
++ return nil
++}
++
++// desktopFacility is whether this computer could show a clone prompt right now:
++// a graphical session with its helper present. ask rechecks before showing.
++func desktopFacility() bool {
++ switch runtime.GOOS {
++ case "linux":
++ if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
++ return false
++ }
++ _, err := exec.LookPath("notify-send")
++ return err == nil
++ case "darwin":
++ _, err := exec.LookPath("osascript")
++ return err == nil
++ }
++ return false
++}
++
++// notifySendActions is whether this notify-send shows buttons and waits for one
++// (libnotify 0.7.10+), asked once per process.
++var notifySendActions = sync.OnceValue(func() bool {
++ out, err := exec.Command("notify-send", "--help").Output()
++ if err != nil {
++ return false
++ }
++ return bytes.Contains(out, []byte("--action")) && bytes.Contains(out, []byte("--wait"))
++})
++
++// askNotifySend asks with a notification with Approve and Deny buttons. Without
++// button support it leaves an informational notification and reports nil, so the
++// terminal stays the decider; without a session to show it in, nil as well.
++func askNotifySend(ctx context.Context, title, body string, giveUp time.Duration) *bool {
++ if !notifySendActions() {
++ _ = runDetached("notify-send", "--app-name=OpenVibe Link", "--urgency=critical", "--", title,
++ body+"\nAnswer in the terminal that runs git connect.")
++ return nil
++ }
++ argv := []string{"notify-send", "--app-name=OpenVibe Link", "--urgency=critical", "--wait",
++ "--action=approve=Approve", "--action=deny=Deny"}
++ if giveUp > 0 {
++ argv = append(argv, "--expire-time="+itoa(int64(giveUp/time.Millisecond)))
++ }
++ argv = append(argv, "--", title, body)
++ out, ok := runPrompt(ctx, argv[0], argv[1:]...)
++ if !ok {
++ return nil
++ }
++ switch strings.TrimSpace(out) {
++ case "approve":
++ return boolp(true)
++ default:
++ // Deny, a dismissal, or the notification expiring with the request: no.
++ return boolp(false)
++ }
++}
++
++// askDialog asks with an osascript dialog, Deny as its default button, giving up
++// with the request. A dialog that never appeared (no GUI session) is nil.
++func askDialog(ctx context.Context, title, body string, giveUp time.Duration) *bool {
++ script := "display dialog " + osaQuote(body) + " with title " + osaQuote(title) +
++ ` buttons {"Deny", "Approve"} default button "Deny"`
++ if giveUp > 0 {
++ script += " giving up after " + itoa(int64((giveUp+time.Second-1)/time.Second))
++ }
++ script += " with icon caution"
++ out, ok := runPrompt(ctx, "osascript", "-e", script)
++ if !ok {
++ return nil
++ }
++ if strings.Contains(out, "button returned:Approve") {
++ return boolp(true)
++ }
++ // Deny, giving up, or closing the dialog: no.
++ return boolp(false)
++}
++
++// osaQuote quotes AppleScript text the way the computer plugin does.
++func osaQuote(s string) string {
++ return `"` + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), `"`, `\"`) + `"`
++}
++
++// runPrompt runs one desktop prompt and returns what it printed. False means it
++// never asked (it would not start); ending the context takes a shown prompt down
++// and is also false, so the caller falls back to whatever else can ask.
++func runPrompt(ctx context.Context, name string, argv ...string) (string, bool) {
++ cmd := exec.Command(name, argv...)
++ cmd.Stdin = nil
++ var out bytes.Buffer
++ cmd.Stdout = &out
++ cmd.Stderr = io.Discard
++ if err := cmd.Start(); err != nil {
++ return "", false
++ }
++ done := make(chan error, 1)
++ go func() { done <- cmd.Wait() }()
++ select {
++ case err := <-done:
++ if err != nil {
++ return "", false
++ }
++ return out.String(), true
++ case <-ctx.Done():
++ interruptPrompt(cmd, done)
++ <-done
++ return "", false
++ }
++}
++
++// interruptPrompt takes a shown prompt down: SIGINT first (notify-send --wait
++// withdraws its notification on it), then SIGKILL if it stays. done is the one
++// waiter that reaps the process; this only signals.
++func interruptPrompt(cmd *exec.Cmd, done <-chan error) {
++ if cmd.Process == nil {
++ return
++ }
++ _ = cmd.Process.Signal(syscall.SIGINT)
++ select {
++ case <-done:
++ case <-time.After(2 * time.Second):
++ _ = cmd.Process.Kill()
++ }
++}
++
++// runDetached starts a fire-and-forget helper and reports whether it started.
++func runDetached(name string, argv ...string) bool {
++ cmd := exec.Command(name, argv...)
++ cmd.Stdin = nil
++ cmd.Stdout = io.Discard
++ cmd.Stderr = io.Discard
++ return cmd.Start() == nil
++}
++
++func boolp(b bool) *bool { return &b }
++
++func itoa(n int64) string {
++ if n == 0 {
++ return "0"
++ }
++ neg := n < 0
++ if neg {
++ n = -n
++ }
++ var b [20]byte
++ i := len(b)
++ for n > 0 {
++ i--
++ b[i] = byte('0' + n%10)
++ n /= 10
++ }
++ if neg {
++ i--
++ b[i] = '-'
++ }
++ return string(b[i:])
++}
++
++// isTerminal is whether input is the person's terminal (a character device),
++// rather than a pipe, a file, or nothing.
++func isTerminal(r io.Reader) bool {
++ f, ok := r.(*os.File)
++ if !ok {
++ return false
++ }
++ st, err := f.Stat()
++ if err != nil {
++ return false
++ }
++ return st.Mode()&os.ModeCharDevice != 0
++}
ddiff --git a/cmd/openvibe-link/git_prompt_test.go b/cmd/openvibe-link/git_prompt_test.go
nnew file mode 100644
iindex 0000000..20c11ff
---- /dev/null
++++ b/cmd/openvibe-link/git_prompt_test.go
@@@ -0,0 +1,218 @@
++package main
++
++import (
++ "bytes"
++ "context"
++ "errors"
++ "os"
++ "strings"
++ "testing"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Link/internal/gitrelay"
++)
++
++// fakeDesktop is a desktop prompt the test answers with: avail says a prompt
++// could be shown, answer is Approve/Deny (nil: could not show it after all),
++// and block holds the answer open until closed or the context ends.
++type fakeDesktop struct {
++ calls *int
++ avail bool
++ answer *bool
++ block chan struct{}
++ title *string
++ body *string
++ giveUp *time.Duration
++}
++
++func (f *fakeDesktop) available() bool { return f.avail }
++
++func (f *fakeDesktop) ask(ctx context.Context, title, body string, giveUp time.Duration) *bool {
++ *f.calls++
++ if f.title != nil {
++ *f.title, *f.body, *f.giveUp = title, body, giveUp
++ }
++ if f.block != nil {
++ select {
++ case <-f.block:
++ case <-ctx.Done():
++ return nil
++ }
++ }
++ return f.answer
++}
++
++func showDesktop(calls *int, answer *bool) *fakeDesktop {
++ return &fakeDesktop{calls: calls, avail: true, answer: answer}
++}
++
++func cloneReq() gitrelay.CloneRequest {
++ return gitrelay.CloneRequest{ID: "clr_01JQZ8M4T7Y2N5K3B6V9C1X0DA", Repo: "openvibers/demo",
++ ExpiresAt: time.Now().Add(90 * time.Second), RequestedBy: "sam"}
++}
++
++// The desktop's Approve decides even when the piped input would refuse: a pipe
++// is not the person.
++func TestDesktopApproveDecides(t *testing.T) {
++ var calls int
++ var out bytes.Buffer
++ approve, _ := cloneApproverWithDesktop(t.TempDir(), false, strings.NewReader(""), &out, showDesktop(&calls, boolp(true)))
++ got, err := approve(context.Background(), cloneReq())
++ if err != nil || !got.Approve {
++ t.Fatalf("approve=%v err=%v", got.Approve, err)
++ }
++ if calls != 1 {
++ t.Fatalf("desktop calls=%d, want 1", calls)
++ }
++}
++
++// The desktop's Deny refuses; nothing else is asked.
++func TestDesktopDenyRefuses(t *testing.T) {
++ approve, _ := cloneApproverWithDesktop(t.TempDir(), false, strings.NewReader("y\n"), new(bytes.Buffer), showDesktop(new(int), boolp(false)))
++ got, err := approve(context.Background(), cloneReq())
++ if err != nil || got.Approve {
++ t.Fatalf("approve=%v err=%v (a Deny must refuse)", got.Approve, err)
++ }
++}
++
++// With no desktop the piped input still decides, as before.
++func TestNoDesktopFallsBackToTerminal(t *testing.T) {
++ desktop := &fakeDesktop{calls: new(int)}
++ for in, want := range map[string]bool{"y\n": true, "n\n": false} {
++ var out bytes.Buffer
++ approve, _ := cloneApproverWithDesktop(t.TempDir(), false, strings.NewReader(in), &out, desktop)
++ got, err := approve(context.Background(), cloneReq())
++ if err != nil || got.Approve != want {
++ t.Fatalf("%q: approve=%v err=%v want %v", in, got.Approve, err, want)
++ }
++ }
++ if *desktop.calls != 0 {
++ t.Fatalf("desktop calls=%d, want 0: an unavailable desktop asks nothing", *desktop.calls)
++ }
++}
++
++// The desktop prompt sees what it approves: the repository, the folder and who asked.
++func TestDesktopSeesWhatItApproves(t *testing.T) {
++ var calls int
++ var title, body string
++ var giveUp time.Duration
++ desktop := showDesktop(&calls, boolp(true))
++ desktop.title, desktop.body, desktop.giveUp = &title, &body, &giveUp
++ var out bytes.Buffer
++ approve, _ := cloneApproverWithDesktop(t.TempDir(), false, strings.NewReader(""), &out, desktop)
++ if _, err := approve(context.Background(), cloneReq()); err != nil {
++ t.Fatal(err)
++ }
++ for _, want := range []string{"openvibers/demo", "demo", "sam"} {
++ if !strings.Contains(title+" "+body, want) {
++ t.Fatalf("prompt %q %q does not say %q", title, body, want)
++ }
++ }
++ if giveUp <= 0 || giveUp > 90*time.Second {
++ t.Fatalf("giveUp=%v, want the time left on the request", giveUp)
++ }
++}
++
++// An expired request asks nothing anywhere: no desktop call, no terminal read.
++func TestExpiredRequestAsksNothing(t *testing.T) {
++ var calls int
++ approve, _ := cloneApproverWithDesktop(t.TempDir(), false, strings.NewReader("y\n"), new(bytes.Buffer), showDesktop(&calls, boolp(true)))
++ req := cloneReq()
++ req.ExpiresAt = time.Now().Add(-time.Second)
++ if _, err := approve(context.Background(), req); !errors.Is(err, errCloneExpired) {
++ t.Fatalf("err=%v, want the expiry", err)
++ }
++ if calls != 0 {
++ t.Fatalf("desktop calls=%d, want 0", calls)
++ }
++}
++
++// No terminal and no desktop is an error naming both, not a silent refusal.
++func TestNoTerminalNoDesktopErrors(t *testing.T) {
++ approve, _ := cloneApproverWithDesktop(t.TempDir(), false, nil, new(bytes.Buffer), &fakeDesktop{calls: new(int)})
++ if _, err := approve(context.Background(), cloneReq()); err == nil || !strings.Contains(err.Error(), "no terminal") {
++ t.Fatalf("err=%v, want the no-one-to-ask error", err)
++ }
++}
++
++// No terminal but a desktop: the desktop alone decides (the deep link's shape).
++func TestDesktopAloneDecides(t *testing.T) {
++ approve, _ := cloneApproverWithDesktop(t.TempDir(), false, nil, new(bytes.Buffer), showDesktop(new(int), boolp(true)))
++ got, err := approve(context.Background(), cloneReq())
++ if err != nil || !got.Approve {
++ t.Fatalf("approve=%v err=%v", got.Approve, err)
++ }
++}
++
++// A cancelled run stops asking and reports the cancellation.
++func TestCancelledRunStopsAsking(t *testing.T) {
++ block := make(chan struct{})
++ defer close(block)
++ approve, _ := cloneApproverWithDesktop(t.TempDir(), false, strings.NewReader(""), new(bytes.Buffer),
++ &fakeDesktop{calls: new(int), avail: true, answer: boolp(true), block: block})
++ ctx, stop := context.WithCancel(context.Background())
++ stop()
++ if _, err := approve(ctx, cloneReq()); !errors.Is(err, context.Canceled) {
++ t.Fatalf("err=%v, want context.Canceled", err)
++ }
++}
++
++// funcDesktop is a desktop prompt from one function, for answers fakeDesktop
++// cannot give.
++type funcDesktop struct {
++ avail bool
++ askFn func(ctx context.Context, title, body string, giveUp time.Duration) *bool
++}
++
++func (f *funcDesktop) available() bool { return f.avail }
++
++func (f *funcDesktop) ask(ctx context.Context, title, body string, giveUp time.Duration) *bool {
++ return f.askFn(ctx, title, body, giveUp)
++}
++
++// The terminal's answer takes a held-open desktop prompt down with it: the
++// prompt below only ends when its context ends.
++func TestTerminalAnswerTakesDesktopDown(t *testing.T) {
++ null, err := os.OpenFile(os.DevNull, os.O_RDONLY, 0)
++ if err != nil {
++ t.Skip("no null device")
++ }
++ defer null.Close()
++ if !isTerminal(null) {
++ t.Skip("no terminal-like file here")
++ }
++ down := make(chan struct{})
++ desktop := &funcDesktop{avail: true, askFn: func(ctx context.Context, _ string, _ string, _ time.Duration) *bool {
++ <-ctx.Done()
++ close(down)
++ return nil
++ }}
++ var out bytes.Buffer
++ approve, _ := cloneApproverWithDesktop(t.TempDir(), false, null, &out, desktop)
++ got, err := approve(context.Background(), cloneReq())
++ if err != nil || got.Approve {
++ t.Fatalf("approve=%v err=%v (a closed input refuses)", got.Approve, err)
++ }
++ select {
++ case <-down:
++ case <-time.After(5 * time.Second):
++ t.Fatal("the desktop prompt was not taken down after the terminal answered")
++ }
++}
++
++// The non-empty folder's second answer can also come from the desktop.
++func TestOverwriteSecondAnswerDesktop(t *testing.T) {
++ for _, want := range []bool{true, false} {
++ _, overwrite := cloneApproverWithDesktop(t.TempDir(), false, strings.NewReader(""), new(bytes.Buffer), showDesktop(new(int), boolp(want)))
++ got, err := overwrite(context.Background(), cloneReq(), "/tmp/demo")
++ if err != nil || got != want {
++ t.Fatalf("overwrite=%v err=%v want %v", got, err, want)
++ }
++ }
++}
++
++func TestOsaQuote(t *testing.T) {
++ if got := osaQuote(`say "hi" \ bye`); got != `"say \"hi\" \\ bye"` {
++ t.Fatalf("quote=%q", got)
++ }
++}
ddiff --git a/cmd/openvibe-link/main.go b/cmd/openvibe-link/main.go
iindex d05cb0a..cc090bc 100644
---- a/cmd/openvibe-link/main.go
++++ b/cmd/openvibe-link/main.go
@@@ -105,7 +105,7 @@ Usage:
openvibe-link share add|list|remove … the shares the Link service keeps up (openvibe-link share)
openvibe-link party host <party-id> host a shared browser for an openvibe.party room, until stopped
openvibe-link clone <LINK> clone a repository here: the "Clone to my computer" link from
-- its page on git.openvibe.codes, approved in this terminal
++ its page on git.openvibe.codes, approved on this computer
openvibe-link git connect <LINK> publish the repositories under --root to OpenVibe Git, read-only,
until Ctrl-C (the link is on your Git page)
openvibe-link version