openvibers/openvibe.link Commits
Link's first run: the answer turns on what each use says (#128)
Changes
@@@ -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),@@@ -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"`@@@ -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") {@@@ -4,6 +4,7 @@ package nodeimport ("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) {@@@ -137,11 +137,13 @@ type Node struct {inputEnded time.TimetrayToldAt 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@@@ -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 panelSavedif 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.Answeredn.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]@@@ -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 {@@@ -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"))@@@ -2,6 +2,7 @@ package panelimport ("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, "", "")
@@ -37,6 +37,7 @@ A restart makes a new port and a new token: an old link stops working.
@@ -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/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 |
| `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 |
| `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 |
| `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),
`state` omits what the Node cannot fill truthfully: `devices` (the paired account's machine list is not read here),
@@ -34,6 +34,50 @@ const (
@@ -34,6 +34,50 @@ const (
// Steps is the order the guide runs them in.
// Steps is the order the guide runs them in.
var Steps = []string{StepInstalled, StepPaired, StepCheck, StepFirstJob}
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.
// Step is one reached step: when, whether the self-check passed, and what the first job found.
type Step struct {
type Step struct {
Name string `json:"name"`
Name string `json:"name"`
@@ -7,6 +7,7 @@ import (
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"net/http/httptest"
"os"
"os"
"path/filepath"
"path/filepath"
+ "reflect"
"runtime"
"runtime"
"strings"
"strings"
"testing"
"testing"
@@ -78,6 +79,42 @@ func TestEventsCarryOnlyTheStepAndMachineCounts(t *testing.T) {
@@ -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) {
func TestSafeReasonDropsAddressesAndPaths(t *testing.T) {
got := SafeReason("dial tcp 10.1.2.3:443: connect: refused; /home/alice/.config\nnext line")
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") {
if strings.Contains(got, "10.1.2.3") || strings.Contains(got, "/home/") || strings.Contains(got, "\n") {
@@ -4,6 +4,7 @@ package node
@@ -4,6 +4,7 @@ package node
import (
import (
"encoding/json"
"encoding/json"
+ "runtime"
"testing"
"testing"
"time"
"time"
@@ -34,6 +35,41 @@ func TestFirstRunEventsGoOnTheExistingLink(t *testing.T) {
@@ -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) {
func TestFirstRunControlRefusesAnythingElse(t *testing.T) {
e := start(t)
e := start(t)
bad := func(data []byte, why string) {
bad := func(data []byte, why string) {
@@ -137,11 +137,13 @@ type Node struct {
@@ -137,11 +137,13 @@ type Node struct {
inputEnded time.Time
inputEnded time.Time
trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals
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 keeps OpenVibe Connect shares up and lets the window add or end one.
shares *shareSet
shares *shareSet
}
}
@@ -183,9 +185,10 @@ func New(opt Options) (*Node, error) {
@@ -183,9 +185,10 @@ func New(opt Options) (*Node, error) {
if err != nil {
if err != nil {
opt.Log.Error("latch", "err", err)
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),
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{},
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)
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
var specs []plugins.Spec
var specs []plugins.Spec
@@ -16,6 +16,7 @@ import (
@@ -16,6 +16,7 @@ import (
"strings"
"strings"
"time"
"time"
+ "github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
"github.com/OpenVibers/OpenVibe.Node/internal/panel"
"github.com/OpenVibers/OpenVibe.Node/internal/panel"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
@@ -53,31 +54,49 @@ var capabilityForKind = map[string]string{
@@ -53,31 +54,49 @@ var capabilityForKind = map[string]string{
protocol.KindAI: "ai",
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"
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 {
type panelSaved struct {
Off []string `json:"off"`
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{}
off := map[string]bool{}
b, err := os.ReadFile(filepath.Join(dir, panelFile))
b, err := os.ReadFile(filepath.Join(dir, panelFile))
if err != nil {
if err != nil {
- return off
+ return off, nil
}
}
var s panelSaved
var s panelSaved
if json.Unmarshal(b, &s) != nil {
if json.Unmarshal(b, &s) != nil {
- return off
+ return off, nil
}
}
for _, id := range s.Off {
for _, id := range s.Off {
off[id] = true
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 {
for id := range off {
s.Off = append(s.Off, id)
s.Off = append(s.Off, id)
}
}
@@ -107,6 +126,7 @@ func (n *Node) startPanel(ctx context.Context) {
@@ -107,6 +126,7 @@ func (n *Node) startPanel(ctx context.Context) {
SetCapability: n.setCapability,
SetCapability: n.setCapability,
Share: n.shareAdd,
Share: n.shareAdd,
Unshare: n.shareRemove,
Unshare: n.shareRemove,
+ FirstRun: n.applyFirstRun,
Log: n.log,
Log: n.log,
})
})
if err != nil {
if err != nil {
@@ -156,6 +176,7 @@ func (n *Node) panelState() panel.State {
@@ -156,6 +176,7 @@ func (n *Node) panelState() panel.State {
}
}
n.mu.Lock()
n.mu.Lock()
out.Activity = append([]panel.Activity(nil), n.activity...)
out.Activity = append([]panel.Activity(nil), n.activity...)
+ out.FirstRun = n.panelFirst == nil || !n.panelFirst.Answered
n.mu.Unlock()
n.mu.Unlock()
return out
return out
}
}
@@ -260,7 +281,7 @@ func (n *Node) setCapability(id string, on bool) error {
@@ -260,7 +281,7 @@ func (n *Node) setCapability(id string, on bool) error {
} else {
} else {
next[id] = true
next[id] = true
}
}
- err := savePanelOff(n.opt.Paths.StateDir, next)
+ err := savePanel(n.opt.Paths.StateDir, next, n.panelFirst)
if err == nil {
if err == nil {
n.panelOff = next
n.panelOff = next
}
}
@@ -276,6 +297,67 @@ func (n *Node) setCapability(id string, on bool) error {
@@ -276,6 +297,67 @@ func (n *Node) setCapability(id string, on bool) error {
return nil
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.
// capabilityOff reports whether a command kind is refused by a switch turned off here.
func (n *Node) capabilityOff(kind string) bool {
func (n *Node) capabilityOff(kind string) bool {
id := capabilityForKind[kind]
id := capabilityForKind[kind]
@@ -8,6 +8,7 @@ import (
@@ -8,6 +8,7 @@ import (
"io"
"io"
"log/slog"
"log/slog"
"os"
"os"
+ "reflect"
"strings"
"strings"
"testing"
"testing"
@@ -135,6 +136,135 @@ func TestPanelStateComesFromTheNode(t *testing.T) {
@@ -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) {
func TestPanelURLIsEmptyBeforeRun(t *testing.T) {
dir, err := os.MkdirTemp("", "ovp")
dir, err := os.MkdirTemp("", "ovp")
if err != nil {
if err != nil {
@@ -21,6 +21,8 @@ import (
@@ -21,6 +21,8 @@ import (
"strconv"
"strconv"
"strings"
"strings"
"time"
"time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/firstrun"
)
)
//go:embed ui/index.html ui/panel.css ui/panel.js
//go:embed ui/index.html ui/panel.css ui/panel.js
@@ -113,6 +115,13 @@ type State struct {
@@ -113,6 +115,13 @@ type State struct {
FirstRun bool `json:"first_run,omitempty"`
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.
// Options are what the daemon gives the panel: its state, and the actions the window can ask for.
type Options struct {
type Options struct {
// Token is the per-run secret the page sends as X-Link-Panel. Required.
// Token is the per-run secret the page sends as X-Link-Panel. Required.
@@ -128,7 +137,10 @@ type Options struct {
@@ -128,7 +137,10 @@ type Options struct {
// Share shares a local port through Connect; Unshare ends that share.
// Share shares a local port through Connect; Unshare ends that share.
Share func(port int) (Share, error)
Share func(port int) (Share, error)
Unshare func(port int) 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.
// Server is one running panel server.
@@ -326,6 +338,29 @@ func (s *Server) api() http.Handler {
@@ -326,6 +338,29 @@ func (s *Server) api() http.Handler {
return
return
}
}
w.WriteHeader(http.StatusNoContent)
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:
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.
// 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"))
writeJSON(w, http.StatusNotImplemented, errBody("sending files between devices is not available in this version of Link"))
@@ -2,6 +2,7 @@ package panel
@@ -2,6 +2,7 @@ package panel
import (
import (
"encoding/json"
"encoding/json"
+ "errors"
"io/fs"
"io/fs"
"net"
"net"
"net/http"
"net/http"
@@ -238,6 +239,60 @@ func TestStopCapabilityAndSharesReachTheDaemon(t *testing.T) {
@@ -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) {
func TestFilesAnswer501WithAReason(t *testing.T) {
srv := testServer(t, Options{})
srv := testServer(t, Options{})
w := do(t, srv, http.MethodPost, "/panel/api/files", testToken, "", "")
w := do(t, srv, http.MethodPost, "/panel/api/files", testToken, "", "")