openvibers/openvibe.link Commits

Link's first run: the answer turns on what each use says (#128)

OP OpenVibers committed 6 days ago · 0fd16aa147 · 9 files, +438 −15

The window's question is answered for real. first_run in the panel state is true until
the person answers or skips, and the answer is kept in <state>/panel.json beside the
capability switches, so a restart does not ask again. A paired Node whose panel.json
predates the question (its switches already saved) is not asked.

POST /panel/api/first-run takes { uses, skip } behind the same token and Origin rules
as every other write, and refuses an unknown use:

- codes records the wish (the Node has no Codes module switch yet); Terminal is never
  turned on by it, and stays gated on the 11-05 review, as does Desktop.
- browser turns the browser capability on.
- models turns the ai capability on.
- earn is recorded only: Exchange confirms it, nothing turns on here.
- vibe turns nothing on.
- skip only stops the question.

One node.first_run event carries the uses (step "uses"), built in internal/firstrun so
it never holds a path, username, address or secret.

Checked: go vet and go test on internal/firstrun, internal/panel and internal/node.

Changes

ddiff --git a/docs/panel.md b/docs/panel.md
iindex 1cb961b..28db687 100644
---- a/docs/panel.md
++++ b/docs/panel.md
@@@ -37,6 +37,7 @@ A restart makes a new port and a new token: an old link stops working.
| `POST /panel/api/capabilities/:id` `{ "on": false }` | Turns a capability off here | Real; persisted in `<state>/panel.json` |
| `POST /panel/api/shares` `{ "port": 3000 }` | Shares a port through Connect | Real for a share Connect already picked for this computer |
| `DELETE /panel/api/shares/:port` | Ends that share | Real |
++| `POST /panel/api/first-run` `{ "uses": ["codes", "browser"] }` | The first-run answer: records it in `<state>/panel.json`, turns on a browser (`browser`) or the AI plugin (`models`), and stops asking. `codes` and `earn` are recorded only (Codes has no module switch yet, and Exchange confirms earning); `vibe` turns nothing on; `skip: true` only stops asking | Real; never turns on Terminal or Desktop |
| `POST /panel/api/files` | Sends files to your other devices | `501` with a reason: not in the daemon yet |
`state` omits what the Node cannot fill truthfully: `devices` (the paired account's machine list is not read here),
ddiff --git a/internal/firstrun/firstrun.go b/internal/firstrun/firstrun.go
iindex 6823ed9..2c6a8bc 100644
---- a/internal/firstrun/firstrun.go
++++ b/internal/firstrun/firstrun.go
@@@ -34,6 +34,50 @@ const (
// Steps is the order the guide runs them in.
var Steps = []string{StepInstalled, StepPaired, StepCheck, StepFirstJob}
++// UsesStep is the node.first_run event that carries the window's answer to "What's this computer for?". It is not in
++// Steps: the funnel counts the guide's four steps, and this one carries what the person said.
++const UsesStep = "uses"
++
++// Uses are the answers the window offers, in its order.
++var Uses = []string{"codes", "browser", "models", "earn", "vibe"}
++
++// ValidUse reports whether u is one of the answers the window offers.
++func ValidUse(u string) bool {
++ for _, use := range Uses {
++ if use == u {
++ return true
++ }
++ }
++ return false
++}
++
++// CleanUses keeps the known answers, without repeats, in the window's order, so an event has a fixed shape.
++func CleanUses(in []string) []string {
++ have := make(map[string]bool, len(in))
++ for _, u := range in {
++ if ValidUse(u) {
++ have[u] = true
++ }
++ }
++ out := make([]string, 0, len(have))
++ for _, u := range Uses {
++ if have[u] {
++ out = append(out, u)
++ }
++ }
++ return out
++}
++
++// UsesEvent is the one node.first_run event that carries the person's answer: the step, the uses, and the version, OS
++// and architecture every event carries. It never carries a path, a username, an address or a secret.
++func UsesEvent(uses []string, version, goos, goarch string) protocol.Event {
++ f := map[string]any{"step": UsesStep, "uses": CleanUses(uses), "version": version, "os": goos}
++ if goarch != "" {
++ f["arch"] = goarch
++ }
++ return protocol.Event{Name: EventName, TS: time.Now().UnixMilli(), Fields: f}
++}
++
// Step is one reached step: when, whether the self-check passed, and what the first job found.
type Step struct {
Name string `json:"name"`
ddiff --git a/internal/firstrun/firstrun_test.go b/internal/firstrun/firstrun_test.go
iindex 89c0ef1..68e7bd0 100644
---- a/internal/firstrun/firstrun_test.go
++++ b/internal/firstrun/firstrun_test.go
@@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
++ "reflect"
"runtime"
"strings"
"testing"
@@@ -78,6 +79,42 @@ func TestEventsCarryOnlyTheStepAndMachineCounts(t *testing.T) {
}
}
++func TestUsesEventCarriesOnlyTheAnswer(t *testing.T) {
++ e := UsesEvent([]string{"browser", "codes", "browser", "nope"}, "v9", "linux", "amd64")
++ if e.Name != EventName || e.Fields["step"] != UsesStep || e.Fields["version"] != "v9" || e.Fields["os"] != "linux" || e.Fields["arch"] != "amd64" {
++ t.Fatalf("event %+v", e)
++ }
++ // Known uses, once each, in the window's order.
++ if got := e.Fields["uses"]; !reflect.DeepEqual(got, []string{"codes", "browser"}) {
++ t.Fatalf("uses %v", got)
++ }
++ // Nothing a person owns goes into the event.
++ b, _ := json.Marshal(e)
++ host, _ := os.Hostname()
++ for _, secret := range []string{host, os.Getenv("USER"), os.Getenv("HOME"), "/home/", "/tmp/"} {
++ if secret != "" && strings.Contains(string(b), secret) {
++ t.Fatalf("%q in the answer event: %s", secret, b)
++ }
++ }
++}
++
++func TestValidAndCleanUses(t *testing.T) {
++ for _, use := range Uses {
++ if !ValidUse(use) {
++ t.Fatalf("%q is not accepted", use)
++ }
++ }
++ if ValidUse("nope") {
++ t.Fatal("an unknown use was accepted")
++ }
++ if got := CleanUses([]string{"vibe", "models"}); !reflect.DeepEqual(got, []string{"models", "vibe"}) {
++ t.Fatalf("clean %v", got)
++ }
++ if got := CleanUses([]string{"nope"}); len(got) != 0 {
++ t.Fatalf("clean of an unknown use: %v", got)
++ }
++}
++
func TestSafeReasonDropsAddressesAndPaths(t *testing.T) {
got := SafeReason("dial tcp 10.1.2.3:443: connect: refused; /home/alice/.config\nnext line")
if strings.Contains(got, "10.1.2.3") || strings.Contains(got, "/home/") || strings.Contains(got, "\n") {
ddiff --git a/internal/node/firstrun_test.go b/internal/node/firstrun_test.go
iindex 2610bf9..f981f62 100644
---- a/internal/node/firstrun_test.go
++++ b/internal/node/firstrun_test.go
@@@ -4,6 +4,7 @@ package node
import (
"encoding/json"
++ "runtime"
"testing"
"time"
@@@ -34,6 +35,41 @@ func TestFirstRunEventsGoOnTheExistingLink(t *testing.T) {
}
}
++// The window's answer goes to Bot as one node.first_run event with the uses, on the link the Node already has.
++func TestFirstRunAnswerIsOneEventWithTheUses(t *testing.T) {
++ e := start(t)
++ if !e.node.panelState().FirstRun {
++ t.Fatal("a fresh machine is not asked what it is for")
++ }
++ if err := e.node.applyFirstRun([]string{"codes", "earn"}, false); err != nil {
++ t.Fatal(err)
++ }
++ var got protocol.Event
++ _, err := e.conn.Expect(protocol.TypeTelemetry, 3*time.Second, func(m protocol.Message) bool {
++ tel, ok := m.(protocol.Telemetry)
++ if !ok {
++ return false
++ }
++ for _, ev := range tel.Events {
++ if ev.Name == firstrun.EventName && ev.Fields["step"] == firstrun.UsesStep {
++ got = ev
++ return true
++ }
++ }
++ return false
++ })
++ if err != nil {
++ t.Fatal(err)
++ }
++ uses, _ := json.Marshal(got.Fields["uses"])
++ if string(uses) != `["codes","earn"]` || got.Fields["version"] != "test" || got.Fields["os"] != runtime.GOOS {
++ t.Fatalf("answer event %+v", got)
++ }
++ if e.node.panelState().FirstRun {
++ t.Fatal("the question is still shown after the answer")
++ }
++}
++
func TestFirstRunControlRefusesAnythingElse(t *testing.T) {
e := start(t)
bad := func(data []byte, why string) {
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 44b5a64..8282f77 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -137,11 +137,13 @@ type Node struct {
inputEnded time.Time
trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals
-- // panel is Link's own window (nil until Run started it). panelOff are the capability switches turned off in it,
-- // kept in state/panel.json; activity is what "What happened here" lists, newest first.
-- panel *panel.Server
-- panelOff map[string]bool
-- activity []panel.Activity
++ // panel is Link's own window (nil until Run started it). panelOff are the capability switches turned off in it and
++ // panelFirst is its first-run answer, both kept in state/panel.json; activity is what "What happened here" lists,
++ // newest first.
++ panel *panel.Server
++ panelOff map[string]bool
++ panelFirst *panelFirstRun
++ activity []panel.Activity
// shares keeps OpenVibe Connect shares up and lets the window add or end one.
shares *shareSet
}
@@@ -183,9 +185,10 @@ func New(opt Options) (*Node, error) {
if err != nil {
opt.Log.Error("latch", "err", err)
}
++ panelOff, panelFirst := loadPanel(opt.Paths.StateDir, opt.Creds != nil)
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{}, inputs: map[string]bool{},
-- panelOff: loadPanelOff(opt.Paths.StateDir)}
++ panelOff: panelOff, panelFirst: panelFirst}
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
var specs []plugins.Spec
ddiff --git a/internal/node/panel.go b/internal/node/panel.go
iindex 74c4daa..bd0def8 100644
---- a/internal/node/panel.go
++++ b/internal/node/panel.go
@@@ -16,6 +16,7 @@ import (
"strings"
"time"
++ "github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
"github.com/OpenVibers/OpenVibe.Node/internal/panel"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
@@@ -53,31 +54,49 @@ var capabilityForKind = map[string]string{
protocol.KindAI: "ai",
}
--// panelFile is where the switches the person turned off are kept, so a restart keeps them.
++// panelFile is where the switches the person turned off and the first-run answer are kept, so a restart keeps them.
const panelFile = "panel.json"
++// panelFirstRun is the answer to "What's this computer for?": answered once the person answered or skipped it, and the
++// uses they picked. It is kept beside the capability switches so a restart does not ask again.
++type panelFirstRun struct {
++ Answered bool `json:"answered"`
++ Uses []string `json:"uses,omitempty"`
++}
++
type panelSaved struct {
Off []string `json:"off"`
++ // FirstRun is absent in a file written before the question existed.
++ FirstRun *panelFirstRun `json:"first_run,omitempty"`
}
--func loadPanelOff(dir string) map[string]bool {
++// loadPanel reads the switches turned off and the first-run answer. A file written before the question existed, on a
++// machine that is paired, is a Node that was set up before this change (paired, its capabilities already saved): it
++// is not asked. A machine that never answered and has no such file is asked once.
++func loadPanel(dir string, paired bool) (map[string]bool, *panelFirstRun) {
off := map[string]bool{}
b, err := os.ReadFile(filepath.Join(dir, panelFile))
if err != nil {
-- return off
++ return off, nil
}
var s panelSaved
if json.Unmarshal(b, &s) != nil {
-- return off
++ return off, nil
}
for _, id := range s.Off {
off[id] = true
}
-- return off
++ if s.FirstRun != nil {
++ return off, s.FirstRun
++ }
++ if paired {
++ return off, &panelFirstRun{Answered: true}
++ }
++ return off, nil
}
--func savePanelOff(dir string, off map[string]bool) error {
-- s := panelSaved{Off: []string{}}
++func savePanel(dir string, off map[string]bool, first *panelFirstRun) error {
++ s := panelSaved{Off: []string{}, FirstRun: first}
for id := range off {
s.Off = append(s.Off, id)
}
@@@ -107,6 +126,7 @@ func (n *Node) startPanel(ctx context.Context) {
SetCapability: n.setCapability,
Share: n.shareAdd,
Unshare: n.shareRemove,
++ FirstRun: n.applyFirstRun,
Log: n.log,
})
if err != nil {
@@@ -156,6 +176,7 @@ func (n *Node) panelState() panel.State {
}
n.mu.Lock()
out.Activity = append([]panel.Activity(nil), n.activity...)
++ out.FirstRun = n.panelFirst == nil || !n.panelFirst.Answered
n.mu.Unlock()
return out
}
@@@ -260,7 +281,7 @@ func (n *Node) setCapability(id string, on bool) error {
} else {
next[id] = true
}
-- err := savePanelOff(n.opt.Paths.StateDir, next)
++ err := savePanel(n.opt.Paths.StateDir, next, n.panelFirst)
if err == nil {
n.panelOff = next
}
@@@ -276,6 +297,67 @@ func (n *Node) setCapability(id string, on bool) error {
return nil
}
++// applyFirstRun records the answer to "What's this computer for?" and turns on what each use says: a browser for the
++// agents, or the AI plugin. Codes has no module switch on the Node yet and earn only records the wish (Exchange
++// confirms it); Terminal and Desktop are never turned on here, as they stay gated on the 11-05 review. A skip only
++// stops the question. One node.first_run event carries the answer.
++func (n *Node) applyFirstRun(uses []string, skip bool) error {
++ if skip {
++ uses = nil
++ } else {
++ uses = firstrun.CleanUses(uses)
++ }
++ for _, use := range uses {
++ switch use {
++ case "browser":
++ if err := n.setCapability("browser", true); err != nil {
++ n.log.Warn("first run: the browser stays off", "err", err)
++ }
++ case "models":
++ if err := n.setCapability("ai", true); err != nil {
++ n.log.Warn("first run: models stay off", "err", err)
++ }
++ case "codes":
++ // Recorded only: the Node has no Codes module to switch on, and Terminal stays as the gate allows.
++ case "earn":
++ // Recorded only: Exchange confirms it, and nothing on this machine turns on here.
++ }
++ }
++
++ n.mu.Lock()
++ first := &panelFirstRun{Answered: true, Uses: uses}
++ off := make(map[string]bool, len(n.panelOff))
++ for id, v := range n.panelOff {
++ off[id] = v
++ }
++ err := savePanel(n.opt.Paths.StateDir, off, first)
++ if err == nil {
++ n.panelFirst = first
++ }
++ n.mu.Unlock()
++ if err != nil {
++ return err
++ }
++
++ switch {
++ case skip:
++ n.addActivity("Skipped the first-run question")
++ case len(uses) > 0:
++ n.addActivity("Told Link what this computer is for: " + strings.Join(uses, ", "))
++ default:
++ n.addActivity("Answered the first-run question: nothing to turn on")
++ }
++
++ if !skip && n.reporting() {
++ e := firstrun.UsesEvent(uses, n.opt.Version, runtime.GOOS, runtime.GOARCH)
++ n.mu.Lock()
++ n.events = append(n.events, e)
++ n.mu.Unlock()
++ n.flushTelemetry(true)
++ }
++ return nil
++}
++
// capabilityOff reports whether a command kind is refused by a switch turned off here.
func (n *Node) capabilityOff(kind string) bool {
id := capabilityForKind[kind]
ddiff --git a/internal/node/panel_test.go b/internal/node/panel_test.go
iindex a9c83c6..6b4a1f6 100644
---- a/internal/node/panel_test.go
++++ b/internal/node/panel_test.go
@@@ -8,6 +8,7 @@ import (
"io"
"log/slog"
"os"
++ "reflect"
"strings"
"testing"
@@@ -135,6 +136,135 @@ func TestPanelStateComesFromTheNode(t *testing.T) {
}
}
++func TestFirstRunIsShownThenCleared(t *testing.T) {
++ dir, err := os.MkdirTemp("", "ovp")
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer os.RemoveAll(dir)
++ paths := config.HomePaths(dir)
++ n := panelTestNode(t, paths)
++ if !n.panelState().FirstRun {
++ t.Fatal("a new Node does not ask what the computer is for")
++ }
++ if err := n.applyFirstRun([]string{"vibe"}, false); err != nil {
++ t.Fatal(err)
++ }
++ if n.panelState().FirstRun {
++ t.Fatal("the question is still shown after the answer")
++ }
++ // The answer is kept, so a restart does not ask again.
++ if n2 := panelTestNode(t, paths); n2.panelState().FirstRun {
++ t.Fatal("the question is shown again after a restart")
++ }
++}
++
++func TestFirstRunTurnsOnOnlyWhatEachUseSays(t *testing.T) {
++ dir, err := os.MkdirTemp("", "ovp")
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer os.RemoveAll(dir)
++ paths := config.HomePaths(dir)
++ n := panelTestNode(t, paths)
++ // The uses must turn browser and models on, and must never turn Terminal or Desktop on.
++ for _, id := range []string{"browser", "ai", "terminal", "desk"} {
++ if err := n.setCapability(id, false); err != nil {
++ t.Fatal(err)
++ }
++ }
++ if err := n.applyFirstRun([]string{"codes", "browser", "models", "earn", "vibe"}, false); err != nil {
++ t.Fatal(err)
++ }
++ if n.capabilityOff(protocol.KindBrowser) {
++ t.Fatal("browser was not turned on")
++ }
++ if n.capabilityOff(protocol.KindAI) {
++ t.Fatal("models were not turned on")
++ }
++ // Codes and earn record the wish only; Terminal and Desktop stay exactly as the gate allows (off).
++ if !n.capabilityOff(protocol.KindExec) {
++ t.Fatal("the answer turned Terminal on")
++ }
++ if !n.capabilityOff(protocol.KindDesk) {
++ t.Fatal("the answer turned Desktop on")
++ }
++ n.mu.Lock()
++ uses := append([]string(nil), n.panelFirst.Uses...)
++ off := map[string]bool{}
++ for id, v := range n.panelOff {
++ off[id] = v
++ }
++ n.mu.Unlock()
++ if !reflect.DeepEqual(uses, []string{"codes", "browser", "models", "earn", "vibe"}) {
++ t.Fatalf("recorded uses %v", uses)
++ }
++ for _, id := range []string{"files", "screen", "input"} {
++ if off[id] {
++ t.Fatalf("the answer turned %s off", id)
++ }
++ }
++ // Everything above survives a restart.
++ n2 := panelTestNode(t, paths)
++ if n2.panelState().FirstRun {
++ t.Fatal("the question came back after a restart")
++ }
++ if !n2.capabilityOff(protocol.KindExec) || !n2.capabilityOff(protocol.KindDesk) {
++ t.Fatal("Terminal or Desktop was turned on after a restart")
++ }
++ if n2.capabilityOff(protocol.KindBrowser) || n2.capabilityOff(protocol.KindAI) {
++ t.Fatal("the browser or models setting was lost after a restart")
++ }
++}
++
++func TestFirstRunSkipOnlyStopsAsking(t *testing.T) {
++ dir, err := os.MkdirTemp("", "ovp")
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer os.RemoveAll(dir)
++ n := panelTestNode(t, config.HomePaths(dir))
++ if err := n.setCapability("browser", false); err != nil {
++ t.Fatal(err)
++ }
++ // A skip wins over any uses in the same answer.
++ if err := n.applyFirstRun([]string{"browser"}, true); err != nil {
++ t.Fatal(err)
++ }
++ if n.panelState().FirstRun {
++ t.Fatal("a skip did not stop the question")
++ }
++ if !n.capabilityOff(protocol.KindBrowser) {
++ t.Fatal("a skip turned the browser on")
++ }
++ n.mu.Lock()
++ uses := n.panelFirst.Uses
++ n.mu.Unlock()
++ if len(uses) != 0 {
++ t.Fatalf("a skip recorded uses: %v", uses)
++ }
++}
++
++func TestFirstRunIsNotAskedOnAMachineSetUpBeforeIt(t *testing.T) {
++ dir := t.TempDir()
++ // A panel.json written before the question existed: the switches saved, no first_run. With a pairing, that is a
++ // machine already in use, so it is not asked.
++ if err := savePanel(dir, map[string]bool{"terminal": true}, nil); err != nil {
++ t.Fatal(err)
++ }
++ off, first := loadPanel(dir, true)
++ if first == nil || !first.Answered {
++ t.Fatal("a paired machine with its switches already saved is asked")
++ }
++ if !off["terminal"] {
++ t.Fatal("the saved switches were lost")
++ }
++ // Without a pairing it is a new machine: it is asked.
++ if _, first := loadPanel(dir, false); first != nil {
++ t.Fatal("an unpaired machine is not asked")
++ }
++}
++
func TestPanelURLIsEmptyBeforeRun(t *testing.T) {
dir, err := os.MkdirTemp("", "ovp")
if err != nil {
ddiff --git a/internal/panel/panel.go b/internal/panel/panel.go
iindex e4679b2..d5e4b1e 100644
---- a/internal/panel/panel.go
++++ b/internal/panel/panel.go
@@@ -21,6 +21,8 @@ import (
"strconv"
"strings"
"time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
)
//go:embed ui/index.html ui/panel.css ui/panel.js
@@@ -113,6 +115,13 @@ type State struct {
FirstRun bool `json:"first_run,omitempty"`
}
++// FirstRunAnswer is the body of POST /panel/api/first-run: the uses the person picked, or a skip that only stops the
++// question.
++type FirstRunAnswer struct {
++ Uses []string `json:"uses"`
++ Skip bool `json:"skip"`
++}
++
// Options are what the daemon gives the panel: its state, and the actions the window can ask for.
type Options struct {
// Token is the per-run secret the page sends as X-Link-Panel. Required.
@@@ -128,7 +137,10 @@ type Options struct {
// Share shares a local port through Connect; Unshare ends that share.
Share func(port int) (Share, error)
Unshare func(port int) error
-- Log *slog.Logger
++ // FirstRun records the answer to "What's this computer for?" and turns on what each use says. A skip only stops
++ // the question.
++ FirstRun func(uses []string, skip bool) error
++ Log *slog.Logger
}
// Server is one running panel server.
@@@ -326,6 +338,29 @@ func (s *Server) api() http.Handler {
return
}
w.WriteHeader(http.StatusNoContent)
++ case path == "first-run" && r.Method == http.MethodPost:
++ var body FirstRunAnswer
++ if !decode(w, r, &body) {
++ writeJSON(w, http.StatusBadRequest, errBody("uses is a list of codes, browser, models, earn or vibe, and skip is true or false"))
++ return
++ }
++ if !body.Skip {
++ for _, use := range body.Uses {
++ if !firstrun.ValidUse(use) {
++ writeJSON(w, http.StatusBadRequest, errBody("no use "+strconv.Quote(use)+": uses are codes, browser, models, earn and vibe"))
++ return
++ }
++ }
++ }
++ if s.opt.FirstRun == nil {
++ writeJSON(w, http.StatusServiceUnavailable, errBody("the panel cannot record this answer"))
++ return
++ }
++ if err := s.opt.FirstRun(body.Uses, body.Skip); err != nil {
++ writeJSON(w, http.StatusConflict, errBody(err.Error()))
++ return
++ }
++ writeJSON(w, http.StatusOK, map[string]bool{"first_run": false})
case path == "files" && r.Method == http.MethodPost:
// Sending files between devices is not in the daemon yet; the window already says it could not send them.
writeJSON(w, http.StatusNotImplemented, errBody("sending files between devices is not available in this version of Link"))
ddiff --git a/internal/panel/panel_test.go b/internal/panel/panel_test.go
iindex ebd59c6..1a3ddb0 100644
---- a/internal/panel/panel_test.go
++++ b/internal/panel/panel_test.go
@@@ -2,6 +2,7 @@ package panel
import (
"encoding/json"
++ "errors"
"io/fs"
"net"
"net/http"
@@@ -238,6 +239,60 @@ func TestStopCapabilityAndSharesReachTheDaemon(t *testing.T) {
}
}
++func TestFirstRunIsRecordedAfterTokenAndUseChecks(t *testing.T) {
++ var calls int
++ var gotUses []string
++ var gotSkip bool
++ srv := testServer(t, Options{FirstRun: func(uses []string, skip bool) error {
++ calls++
++ gotUses, gotSkip = uses, skip
++ return nil
++ }})
++
++ // Every write has the same rules: the token, and this server's own origin when one is sent.
++ if w := do(t, srv, http.MethodPost, "/panel/api/first-run", "", srv.Origin(), `{"uses":["browser"]}`); w.Code != http.StatusForbidden {
++ t.Fatalf("no token: %d", w.Code)
++ }
++ if w := do(t, srv, http.MethodPost, "/panel/api/first-run", testToken, "https://evil.example", `{"uses":["browser"]}`); w.Code != http.StatusForbidden {
++ t.Fatalf("wrong origin: %d", w.Code)
++ }
++ // An unknown use is a typo, not something to record.
++ if w := do(t, srv, http.MethodPost, "/panel/api/first-run", testToken, srv.Origin(), `{"uses":["nope"]}`); w.Code != http.StatusBadRequest {
++ t.Fatalf("unknown use: %d %s", w.Code, w.Body)
++ }
++ if calls != 0 {
++ t.Fatalf("the daemon was called %d times before a valid answer", calls)
++ }
++
++ w := do(t, srv, http.MethodPost, "/panel/api/first-run", testToken, srv.Origin(), `{"uses":["codes","browser"]}`)
++ if w.Code != http.StatusOK {
++ t.Fatalf("answer: %d %s", w.Code, w.Body)
++ }
++ var body map[string]bool
++ if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body["first_run"] != false {
++ t.Fatalf("answer body: %s", w.Body)
++ }
++ if !reflect.DeepEqual(gotUses, []string{"codes", "browser"}) || gotSkip {
++ t.Fatalf("daemon got uses %v skip %v", gotUses, gotSkip)
++ }
++
++ if w := do(t, srv, http.MethodPost, "/panel/api/first-run", testToken, srv.Origin(), `{"uses":[],"skip":true}`); w.Code != http.StatusOK || !gotSkip {
++ t.Fatalf("skip: %d %s (skip %v)", w.Code, w.Body, gotSkip)
++ }
++}
++
++func TestFirstRunWithoutAWayToRecordItSaysSo(t *testing.T) {
++ bare := testServer(t, Options{})
++ if w := do(t, bare, http.MethodPost, "/panel/api/first-run", testToken, "", `{"uses":["vibe"]}`); w.Code != http.StatusServiceUnavailable {
++ t.Fatalf("no callback: %d", w.Code)
++ }
++ refuse := testServer(t, Options{FirstRun: func([]string, bool) error { return errors.New("the answer could not be saved") }})
++ w := do(t, refuse, http.MethodPost, "/panel/api/first-run", testToken, "", `{"uses":["vibe"]}`)
++ if w.Code != http.StatusConflict || !strings.Contains(w.Body.String(), "could not be saved") {
++ t.Fatalf("refused: %d %s", w.Code, w.Body)
++ }
++}
++
func TestFilesAnswer501WithAReason(t *testing.T) {
srv := testServer(t, Options{})
w := do(t, srv, http.MethodPost, "/panel/api/files", testToken, "", "")