Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions cmd/daemon/appstore_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,60 @@ package main

import (
"context"
"encoding/json"
"time"

"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
"github.com/pilot-protocol/app-store/plugin/appstore"
"github.com/pilot-protocol/common/coreapi"
)

type appstoreAdapter struct {
svc *appstore.Service
svc *appstore.Service
telemetryURL string
identityPath string
}

// telemetryEmitter wraps the consent-gated telemetry client to satisfy
// the appstore.TelemetryEmitter interface. Events are sent as
// "app_usage" kind with the supervisor-provided fields as payload.
// Best-effort: send errors are logged but never block the caller.
type telemetryEmitter struct {
client *telemetry.Client
}

func (e *telemetryEmitter) Emit(ev appstore.TelemetryEvent) {
if e == nil || e.client == nil {
return
}
payload, err := json.Marshal(ev)
if err != nil {
return
}
_ = e.client.Send(telemetry.Event{
Kind: "app_usage",
TS: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
})
}

func (a *appstoreAdapter) Name() string { return a.svc.Name() }
func (a *appstoreAdapter) Order() int { return a.svc.Order() }
func (a *appstoreAdapter) Start(ctx context.Context, deps coreapi.Deps) error {
// Build a consent-gated telemetry client for app-usage events.
// When the URL is empty or identity is absent the client is a
// permanent no-op — the emitter never sends anything.
client := telemetry.NewClientFromIdentity(a.telemetryURL, a.identityPath, 0)
emitter := &telemetryEmitter{client: client}

return a.svc.Start(ctx, appstore.Deps{
Streams: deps.Streams,
Identity: deps.Identity,
Resolver: deps.Resolver,
Events: deps.Events,
Logger: deps.Logger,
Trust: deps.Trust,
Streams: deps.Streams,
Identity: deps.Identity,
Resolver: deps.Resolver,
Events: deps.Events,
Logger: deps.Logger,
Trust: deps.Trust,
Telemetry: emitter,
})
}
func (a *appstoreAdapter) Stop(ctx context.Context) error { return a.svc.Stop(ctx) }
Expand Down
30 changes: 23 additions & 7 deletions cmd/daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,13 +307,29 @@ func main() {
if home, herr := os.UserHomeDir(); herr == nil {
appstoreInstallRoot = filepath.Join(home, ".pilot", "apps")
}
if err := rt.Register(&appstoreAdapter{svc: appstore.NewService(appstore.Config{
InstallRoot: appstoreInstallRoot,
RescanInterval: 2 * time.Second,
// Real catalogue trust anchor (replaces the all-zeros
// placeholder default): the embedded ed25519 catalogue key.
CatalogPubkey: []byte(catalogtrust.PublicKey()),
})}); err != nil {
// The app-usage telemetry emitter shares the daemon's identity file
// and telemetry URL. When consent is off (empty URL) the client is
// a permanent no-op — no goroutines, no dials, no buffering.
idPath := *identityPath
if idPath == "" {
if home, herr := os.UserHomeDir(); herr == nil {
defaultID := filepath.Join(home, ".pilot", "identity.json")
if _, serr := os.Stat(defaultID); serr == nil {
idPath = defaultID
}
}
}
if err := rt.Register(&appstoreAdapter{
svc: appstore.NewService(appstore.Config{
InstallRoot: appstoreInstallRoot,
RescanInterval: 2 * time.Second,
// Real catalogue trust anchor (replaces the all-zeros
// placeholder default): the embedded ed25519 catalogue key.
CatalogPubkey: []byte(catalogtrust.PublicKey()),
}),
telemetryURL: *telemetryURL,
identityPath: idPath,
}); err != nil {
log.Fatalf("register appstore: %v", err)
}

Expand Down
31 changes: 31 additions & 0 deletions cmd/pilotctl/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"strings"
"time"

"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
"github.com/pilot-protocol/app-store/pkg/ipc"
"github.com/pilot-protocol/app-store/pkg/manifest"
"github.com/pilot-protocol/common/crypto"
Expand Down Expand Up @@ -1211,6 +1212,36 @@ func cmdAppStoreInstall(args []string) {
Reason: reason,
})

// Emit a telemetry event for the successful install (consent-gated —
// no-op when PILOT_TELEMETRY_URL is empty or identity.json is absent).
// Best-effort: a send failure is logged but not fatal — the install
// itself already succeeded on disk.
{
url := os.Getenv("PILOT_TELEMETRY_URL")
if url == "" {
url = telemetry.DefaultEndpoint
}
sourceStr := "catalogue"
if source == installSourceLocal {
sourceStr = "local"
}
payload, _ := json.Marshal(map[string]string{
"app_id": m.ID,
"version": m.AppVersion,
"source": sourceStr,
})
identityPath := configDir() + "/identity.json"
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
err := client.Send(telemetry.Event{
Kind: "app_installed",
TS: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
})
if err != nil {
slog.Warn("telemetry send failed, install still successful", "app", m.ID, "err", err)
}
}

report := installReport{
AppID: m.ID,
AppVersion: m.AppVersion,
Expand Down
22 changes: 22 additions & 0 deletions cmd/pilotctl/appstore_catalogue.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
Expand All @@ -51,6 +52,7 @@ import (
"time"

"github.com/TeoSlayer/pilotprotocol/internal/catalogtrust"
"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
)

// defaultCatalogueURL points at the canonical catalogue.json on main.
Expand Down Expand Up @@ -233,6 +235,26 @@ func cmdAppStoreSignCatalogue(args []string) {
}

func cmdAppStoreCatalogue(_ []string) {
// Emit a telemetry event for the catalogue page view (consent-gated —
// no-op when PILOT_TELEMETRY_URL is empty or identity.json is absent).
// Best-effort, non-blocking: a send failure is logged but doesn't
// prevent the catalogue from rendering.
{
url := os.Getenv("PILOT_TELEMETRY_URL")
if url == "" {
url = telemetry.DefaultEndpoint
}
identityPath := configDir() + "/identity.json"
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
err := client.Send(telemetry.Event{
Kind: "catalogue_viewed",
TS: time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
slog.Warn("telemetry send failed, catalogue still rendered", "err", err)
}
}

c, err := loadCatalogue()
if err != nil {
fatalHint("io_error",
Expand Down
28 changes: 28 additions & 0 deletions cmd/pilotctl/appstore_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ package main
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"

"github.com/pilot-protocol/app-store/pkg/manifest"

"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
)

// installedAppFacts is the verified, local-only band of `view` — derived
Expand Down Expand Up @@ -175,6 +179,30 @@ func cmdAppStoreView(args []string) {
"app %q not found in catalogue or install root", appID)
}

// Emit a telemetry event for the detail view (consent-gated —
// no-op when PILOT_TELEMETRY_URL is empty or identity.json is absent).
// Best-effort: a send failure is logged but not fatal — the view
// itself already resolved and rendered below.
{
url := os.Getenv("PILOT_TELEMETRY_URL")
if url == "" {
url = telemetry.DefaultEndpoint
}
payload, _ := json.Marshal(map[string]string{
"app_id": appID,
})
identityPath := configDir() + "/identity.json"
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
err := client.Send(telemetry.Event{
Kind: "appstore_view",
TS: time.Now().UTC().Format(time.RFC3339),
Payload: payload,
})
if err != nil {
slog.Warn("telemetry send failed, view still shown", "app", appID, "err", err)
}
}

report := buildAppViewReport(appID, entry, meta, facts)
if jsonOutput {
_ = json.NewEncoder(os.Stdout).Encode(report)
Expand Down
30 changes: 30 additions & 0 deletions cmd/pilotctl/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ package main

import (
"bufio"
cryptorand "crypto/rand"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net"
"os"
"os/exec"
Expand All @@ -21,6 +23,7 @@ import (
"syscall"
"time"

"github.com/pilot-protocol/common/consent"
"github.com/pilot-protocol/common/driver"
"github.com/pilot-protocol/common/protocol"
registry "github.com/pilot-protocol/common/registry/client"
Expand Down Expand Up @@ -4183,6 +4186,33 @@ func cmdSendMessage(args []string) {
})
}
tracef("outputOK")
maybePromptPilotReview()
}

// maybePromptPilotReview occasionally prints a Pilot review nudge to stderr
// after a send-message completes. Gated by:
// - reviews consent (default on; no-op when off)
// - PILOT_FLAG_PILOT_REVIEW_PROMPT env / feature flag (default off)
// - 5% random roll
//
// Uses stderr so --json output on stdout is byte-identical when off.
func maybePromptPilotReview() {
if !featureEnabled("pilot.review_prompt") {
return
}
home, _ := os.UserHomeDir()
if !consent.GetConsent(home, "reviews") {
return
}
var seed [32]byte
if _, err := io.ReadFull(cryptorand.Reader, seed[:]); err != nil {
return
}
src := rand.New(rand.NewChaCha8(seed))
if src.Float64() >= 0.05 {
return
}
fmt.Fprintln(os.Stderr, "\nLike Pilot? Run 'pilotctl review pilot --rating N' to leave a review.")
}

func cmdSubscribe(args []string) {
Expand Down
43 changes: 37 additions & 6 deletions cmd/pilotctl/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@
package main

import (
"encoding/json"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"time"

"github.com/pilot-protocol/common/consent"

"github.com/TeoSlayer/pilotprotocol/pkg/telemetry"
)

// reviewHelpText is the canonical help block for `pilotctl review`.
Expand All @@ -30,8 +37,8 @@ Examples:
pilotctl review io.pilot.cosift --rating 4
pilotctl review io.pilot.cosift --text "Very useful app"

Note: telemetry routing is not yet enabled (PILOT-411). This command
validates input and confirms receipt; no data is transmitted.
Reviews are sent to the telemetry endpoint (consent-gated — no-op when
reviews consent is off or PILOT_TELEMETRY_URL is unset).
`

// cmdReview handles `pilotctl review <pilot|app-id> [--rating N] [--text "..."]`.
Expand All @@ -41,11 +48,8 @@ validates input and confirms receipt; no data is transmitted.
// - --rating, when present, must be an integer in [1, 5]
// - --text is free-form (no constraint)
//
// On valid input: prints a confirmation line and exits 0.
// On valid input: routes to telemetry (consent-gated) then confirms.
// On invalid input: prints an error + usage hint to stderr, exits 1.
//
// Telemetry routing (PILOT-411) is not yet implemented; this is a
// validation + stub only.
func cmdReview(args []string) {
flags, pos := parseFlags(args)

Expand Down Expand Up @@ -94,6 +98,33 @@ func cmdReview(args []string) {

reviewText := flagString(flags, "text", "")

// Route to telemetry if reviews consent is on (default on when absent).
// Best-effort: a send failure is logged but not fatal.
home, _ := os.UserHomeDir()
if consent.GetConsent(home, "reviews") {
url := os.Getenv("PILOT_TELEMETRY_URL")
if url == "" {
url = telemetry.DefaultEndpoint
}
identityPath := configDir() + "/identity.json"
payload := map[string]interface{}{"subject": subject}
if hasRating {
payload["rating"] = rating
}
if reviewText != "" {
payload["text"] = reviewText
}
payloadBytes, _ := json.Marshal(payload)
client := telemetry.NewClientFromIdentity(url, identityPath, 0)
if err := client.Send(telemetry.Event{
Kind: "review",
TS: time.Now().UTC().Format(time.RFC3339),
Payload: payloadBytes,
}); err != nil {
slog.Warn("review telemetry send failed, review still accepted", "subject", subject, "err", err)
}
}

if jsonOutput {
out := map[string]interface{}{
"subject": subject,
Expand Down
11 changes: 6 additions & 5 deletions cmd/pilotctl/updates.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ func collapseWhitespace(s string) string {
// also re-runs skill install so newly installed binaries have matching skills.
//
// Flags:
//
// --repo <name> : GitHub owner/repo for releases (default: TeoSlayer/pilotprotocol)
// --pin <tag> : pin to a specific release tag (e.g. v1.10.5)
// (global) --json : emit machine-readable JSON
Expand All @@ -229,11 +230,11 @@ func cmdUpdate(args []string) {
installDir := filepath.Dir(updaterBin)

u := updater.New(updater.Config{
CheckInterval: 0, // unused for RunOnce
Repo: repo,
InstallDir: installDir,
Version: version,
PinnedVersion: pin,
CheckInterval: 0, // unused for RunOnce
Repo: repo,
InstallDir: installDir,
Version: version,
PinnedVersion: pin,
})

u.RunOnce()
Expand Down
Loading
Loading