diff --git a/.changeset/driver-versions-one-place.md b/.changeset/driver-versions-one-place.md new file mode 100644 index 000000000..bc060bb52 --- /dev/null +++ b/.changeset/driver-versions-one-place.md @@ -0,0 +1,13 @@ +--- +"ftw": minor +--- + +Driver versions have one place: each device's Versions list under Settings → +Devices. It shows the release's own copy, the signed stable versions and any beta +newer than stable, each with a "What changed" link to the driver's history, and a +button to check the signed channels for new versions. The device line says where +the running driver comes from and whether a chosen version is kept across +updates; `ftw status` says the same. Update Center and System no longer list or +refresh drivers, and no new version lights up the header. Adding a device can +fetch driver types the release does not carry, from the driver channel or, marked, +from beta, in the same list. diff --git a/docs/device-repository.md b/docs/device-repository.md index df5331c60..ebe474886 100644 --- a/docs/device-repository.md +++ b/docs/device-repository.md @@ -35,6 +35,11 @@ or an update trial that falls back, therefore runs the selection again. `ftw status` lists the version each configured driver runs and, for an override, the release's own version. +Settings → Devices is the one place a driver version is seen and changed. +Each device's Versions list shows the release's copy, the signed stable +versions and any beta newer than stable, with a link to what changed. Nothing +there announces a new version; the owner checks and picks. + Refreshing the signed manifest only updates discovery data. It never installs, activates or restarts a driver. FTW verifies the Ed25519 signature, driver ID, SemVer, host API range, URL, file size and SHA-256. Installation then compiles diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 41bb2f799..9a723e1d9 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -1907,6 +1907,9 @@ func (s *Server) handleDriversCatalog(w http.ResponseWriter, r *http.Request) { if release, err := drivers.ParseCatalogFile(filepath.Join(dir, filepath.FromSlash(rel))); err == nil { entries[i].ReleaseVersion = release.Version } + if entries[i].Source == "managed" && s.deps.DriverRepository != nil { + entries[i].Chosen = s.deps.DriverRepository.Chosen(entries[i].Path, entries[i].Version) + } } writeJSON(w, 200, map[string]any{"path": dir, "entries": entries}) } diff --git a/go/internal/api/api_device_repository.go b/go/internal/api/api_device_repository.go index 227eaa880..6abfae2fb 100644 --- a/go/internal/api/api_device_repository.go +++ b/go/internal/api/api_device_repository.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -64,11 +65,22 @@ func (s *Server) handleDeviceRepositoryRefresh(w http.ResponseWriter, r *http.Re return } } - if err := s.deps.DriverRepository.Refresh(r.Context(), body.RepositoryID); err != nil { + // Without a repository this is the owner's "check for new versions", + // which also reads the beta channel; a beta outage is only a warning. + var warnings []string + var err error + if body.RepositoryID == "" { + warnings, err = s.deps.DriverRepository.RefreshAll(r.Context()) + } else { + err = s.deps.DriverRepository.Refresh(r.Context(), body.RepositoryID) + } + if err != nil { writeJSON(w, 502, map[string]any{"error": err.Error(), "status": s.deps.DriverRepository.Status()}) return } - writeJSON(w, 200, s.deps.DriverRepository.Status()) + status := s.deps.DriverRepository.Status() + status.Warnings = warnings + writeJSON(w, 200, status) } func (s *Server) handleDeviceRepositoryInstall(w http.ResponseWriter, r *http.Request) { @@ -278,7 +290,33 @@ func (s *Server) handleDeviceRepositoryVersions(w http.ResponseWriter, r *http.R writeJSON(w, 500, map[string]string{"error": availableErr.Error()}) return } - writeJSON(w, 200, map[string]any{"driver_id": r.PathValue("id"), "installed": versions, "available": available}) + // The release's own copy and the owner's choice belong in the same list: + // the picker is the one place a driver version is seen and changed. + logicalPath := "" + for _, installed := range versions { + if installed.Active { + logicalPath = installed.LogicalPath + } + } + for _, candidate := range available { + if logicalPath == "" { + logicalPath = candidate.Driver.Path + } + } + release, chosen := "", "" + if logicalPath != "" { + release = s.deps.DriverRepository.ReleaseVersion(logicalPath) + for _, installed := range versions { + if installed.Active && s.deps.DriverRepository.Chosen(logicalPath, installed.Version) { + chosen = installed.Version + } + } + } + writeJSON(w, 200, map[string]any{ + "driver_id": r.PathValue("id"), "installed": versions, "available": available, + "logical_path": logicalPath, "release_version": release, "chosen_version": chosen, + "release_source": s.bundledSource(), + }) } func (s *Server) handleDeviceRepositoryActivate(w http.ResponseWriter, r *http.Request) { @@ -408,6 +446,23 @@ func (r managedDriverRestartState) response(status string, artifact any) map[str "runtime_verified": len(names) > 0, "restarted_drivers": names, "logical_path": r.LogicalPath, "config_changed": r.ConfigChanged} } +// bundledSource names where this release's drivers were taken from: the +// repository and commit pinned in drivers/BUNDLED_SOURCE.json. +func (s *Server) bundledSource() map[string]string { + raw, err := os.ReadFile(filepath.Join(s.deps.DriverDir, "BUNDLED_SOURCE.json")) + if err != nil { + return nil + } + var pin struct { + Repository string `json:"repository"` + Commit string `json:"commit"` + } + if json.Unmarshal(raw, &pin) != nil || pin.Repository == "" || pin.Commit == "" { + return nil + } + return map[string]string{"repository": "https://github.com/" + pin.Repository, "commit": pin.Commit} +} + // sameDriverFile reports whether a driver file, declaring id, holds the // driver of a signed artifact. A driver has one id everywhere: its source, // the signed channel and the copy bundled with this release. diff --git a/go/internal/api/api_device_repository_test.go b/go/internal/api/api_device_repository_test.go index 810fd0c0e..57d026564 100644 --- a/go/internal/api/api_device_repository_test.go +++ b/go/internal/api/api_device_repository_test.go @@ -101,6 +101,7 @@ func newDriverUpdateFixture(t *testing.T, mode string) *driverUpdateFixture { t.Cleanup(func() { _ = st.Close() }) rc := &config.DeviceRepository{Enabled: true, Repositories: []config.DriverRepositorySource{{ID: "test", ManifestURL: f.repo.URL + "/manifest.json", Enabled: true, AllowInsecure: true, TrustedKeys: map[string]string{"test": base64.StdEncoding.EncodeToString(public)}}}} m := driverrepo.New(rc, dir, st) + m.SetBundledDir(bundledDir) tel := telemetry.NewStore() registry := drivers.NewRegistry(tel) registry.RuntimePolicyResolver = m.RuntimePolicy @@ -597,3 +598,38 @@ func TestDriverCatalogCreditsOnlyTheFileThatRuns(t *testing.T) { } } } + +// The picker shows the release's own version and marks the owner's choice, +// and the device card reads the same mark from the catalog. +func TestVersionsShowTheReleaseAndTheOwnersChoice(t *testing.T) { + f := newDriverUpdateFixture(t, "running") + f.publishAs("esphome_dsmr", "esphome_dsmr.lua", "1.0.1") + f.requestFor("esphome_dsmr", "install", `{"repository_id":"test"}`, 200) + + w := httptest.NewRecorder() + f.s.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/device_repository/drivers/esphome_dsmr/versions", nil)) + var versions struct { + Release string `json:"release_version"` + Chosen string `json:"chosen_version"` + } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &versions) != nil { + t.Fatalf("versions: HTTP %d %s", w.Code, w.Body.String()) + } + if versions.Release != "1.0.2" || versions.Chosen != "1.0.1" { + t.Fatalf("versions = %+v; want release 1.0.2 and the chosen 1.0.1", versions) + } + + w = httptest.NewRecorder() + f.s.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/drivers/catalog", nil)) + var catalog struct { + Entries []drivers.CatalogEntry `json:"entries"` + } + if err := json.Unmarshal(w.Body.Bytes(), &catalog); err != nil { + t.Fatal(err) + } + for _, e := range catalog.Entries { + if e.Filename == "esphome_dsmr.lua" && (!e.Chosen || e.ReleaseVersion != "1.0.2") { + t.Fatalf("catalog entry = %+v; want chosen over release 1.0.2", e) + } + } +} diff --git a/go/internal/driverrepo/manager.go b/go/internal/driverrepo/manager.go index 1b0693a7b..44d8dfaf9 100644 --- a/go/internal/driverrepo/manager.go +++ b/go/internal/driverrepo/manager.go @@ -107,12 +107,21 @@ type CatalogCandidate struct { } type VersionCandidate struct { - RepositoryID string `json:"repository_id"` - Driver ManifestDriver `json:"driver"` - Installed *state.DriverRepoInstall `json:"installed,omitempty"` + RepositoryID string `json:"repository_id"` + // Channel is "beta" for the built-in beta channel and "stable" for a + // configured repository. + Channel string `json:"channel"` + // Repository is the signed manifest's source repository URL; with the + // entry's source commit it names the history behind this version. + Repository string `json:"repository,omitempty"` + Driver ManifestDriver `json:"driver"` + Installed *state.DriverRepoInstall `json:"installed,omitempty"` } type Status struct { + // Warnings are problems a refresh met that did not stop it, such as an + // unreachable beta channel. + Warnings []string `json:"warnings,omitempty"` Enabled bool `json:"enabled"` HostAPI int `json:"driver_host_api"` RootDir string `json:"root_dir"` @@ -312,6 +321,20 @@ func (m *Manager) Refresh(ctx context.Context, repositoryID string) error { return errors.Join(errs...) } +// RefreshAll is an owner's "check for new versions": every configured +// source and the beta channel. A beta that cannot be reached is a warning, +// not a failure, so the versions list still redraws from what did refresh. +// The periodic refresh stays on Refresh, which reads only configured sources. +func (m *Manager) RefreshAll(ctx context.Context) (warnings []string, err error) { + if err := m.Refresh(ctx, ""); err != nil { + return nil, err + } + if betaErr := m.refreshOne(ctx, m.betaRepo); betaErr != nil { + warnings = append(warnings, "beta channel: "+betaErr.Error()) + } + return warnings, nil +} + func (m *Manager) refreshOne(ctx context.Context, repo config.DriverRepositorySource) error { if repositoryFormat(repo) == config.DriverRepositoryFormatSourcefulIndexV1 { return m.refreshSourceful(ctx, repo) @@ -735,33 +758,63 @@ func (m *Manager) AvailableVersions(driverID string) ([]VersionCandidate, error) if err != nil { return nil, err } - installedByKey := make(map[string]state.DriverRepoInstall, len(installed)) + // An install is matched by content, not by the channel it came from: a + // beta file that stable later publishes byte for byte is the file that + // runs, whichever row lists it. + installedByContent := make(map[string]state.DriverRepoInstall, len(installed)) for _, artifact := range installed { - installedByKey[artifact.RepoID+"\x00"+artifact.Version+"\x00"+strings.ToLower(artifact.SHA256)] = artifact + key := artifact.Version + "\x00" + strings.ToLower(artifact.SHA256) + if prior, ok := installedByContent[key]; !ok || (artifact.Active && !prior.Active) { + installedByContent[key] = artifact + } } + listed := make(map[int64]bool) var out []VersionCandidate seen := make(map[string]bool) + offered := make(map[string]bool) // by content, so a promoted beta file is listed once + sources := make([]config.DriverRepositorySource, 0, len(m.cfg.Repositories)+1) for _, repo := range m.cfg.Repositories { - if !repo.Enabled { - continue + if repo.Enabled { + sources = append(sources, repo) } + } + if m.cfg.Enabled { + // Cached only: listing versions never reaches the network. + sources = append(sources, m.betaRepo) + } + newestStable := "" + for _, repo := range sources { manifest, err := m.manifestFor(repo) if err != nil { continue } + channel := "stable" + if repo.ID == m.betaRepo.ID { + channel = "beta" + } for _, driver := range append(append([]ManifestDriver{}, manifest.Drivers...), manifest.History...) { if driver.ID != driverID { continue } + if channel == "stable" && (newestStable == "" || compareSemver(driver.Version, newestStable) > 0) { + newestStable = driver.Version + } + // Beta is where the next version waits. Its history repeats + // versions stable has passed, which would only be noise here. + if channel == "beta" && newestStable != "" && compareSemver(driver.Version, newestStable) <= 0 { + continue + } key := repo.ID + "\x00" + driver.Version + "\x00" + strings.ToLower(driver.SHA256) - if seen[key] { + if seen[key] || (channel == "beta" && offered[strings.ToLower(driver.SHA256)]) { continue } seen[key] = true - candidate := VersionCandidate{RepositoryID: repo.ID, Driver: driver} - if artifact, ok := installedByKey[key]; ok { + offered[strings.ToLower(driver.SHA256)] = true + candidate := VersionCandidate{RepositoryID: repo.ID, Channel: channel, Repository: manifest.Repository, Driver: driver} + if artifact, ok := installedByContent[driver.Version+"\x00"+strings.ToLower(driver.SHA256)]; ok { copy := artifact candidate.Installed = © + listed[artifact.ID] = true } out = append(out, candidate) } @@ -772,13 +825,18 @@ func (m *Manager) AvailableVersions(driverID string) ([]VersionCandidate, error) // return to a known local version. for _, artifact := range installed { key := artifact.RepoID + "\x00" + artifact.Version + "\x00" + strings.ToLower(artifact.SHA256) - if seen[key] { + if seen[key] || listed[artifact.ID] { continue } seen[key] = true copy := artifact + channel := "stable" + if artifact.RepoID == m.betaRepo.ID { + channel = "beta" + } out = append(out, VersionCandidate{ RepositoryID: artifact.RepoID, + Channel: channel, Driver: ManifestDriver{ ID: artifact.DriverID, Path: artifact.LogicalPath, Filename: filepath.Base(artifact.InstalledPath), Version: artifact.Version, @@ -928,6 +986,21 @@ func (m *Manager) bundledVersion(logicalPath string) string { return entry.Version } +// ReleaseVersion is the version this Core release bundles at logicalPath, or +// "" when it bundles none there. +func (m *Manager) ReleaseVersion(logicalPath string) string { return m.bundledVersion(logicalPath) } + +// Chosen reports whether the managed selection at logicalPath runs version +// because the owner went back to it from a newer one; such a choice stays +// across Core updates. +func (m *Manager) Chosen(logicalPath, version string) bool { + if m.store == nil || version == "" { + return false + } + pinned, _ := m.store.LoadConfig(pinKey(logicalPath)) + return pinned == version +} + // pinKey holds the version of a managed selection the owner chose over a // newer one that was running. previousPinKey keeps the value it replaced, so // undoing an activation also undoes its effect on the choice. diff --git a/go/internal/driverrepo/versions_test.go b/go/internal/driverrepo/versions_test.go new file mode 100644 index 000000000..e95f717aa --- /dev/null +++ b/go/internal/driverrepo/versions_test.go @@ -0,0 +1,159 @@ +package driverrepo + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/state" +) + +func mustRefreshAll(t *testing.T, manager *Manager) { + t.Helper() + warnings, err := manager.RefreshAll(context.Background()) + if err != nil || len(warnings) != 0 { + t.Fatalf("RefreshAll: %v %v", warnings, err) + } +} + +func signedServer(t *testing.T, version string) (*signedFixture, string, string) { + t.Helper() + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + fixture := &signedFixture{private: private} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/manifest.json": + _, _ = w.Write(fixture.envelope(t)) + case "/demo.lua": + fixture.mu.Lock() + defer fixture.mu.Unlock() + _, _ = w.Write(fixture.driver) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + fixture.setVersion(server.URL, version) + return fixture, server.URL, base64.StdEncoding.EncodeToString(public) +} + +// The version picker is the one place an owner sees and changes a driver +// version, so it lists the beta channel beside stable, labelled, and one +// refresh checks both. +func TestVersionListShowsStableAndBetaFromOneRefresh(t *testing.T) { + _, stableURL, stableKey := signedServer(t, "1.0.0") + beta, betaURL, betaKey := signedServer(t, "1.1.0-beta.1") + + dir := t.TempDir() + store, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + manager := New(&config.DeviceRepository{Enabled: true, Repositories: []config.DriverRepositorySource{{ + ID: "stable", ManifestURL: stableURL + "/manifest.json", Enabled: true, AllowInsecure: true, + TrustedKeys: map[string]string{"test": stableKey}, + }}}, dir, store) + manager.betaRepo = config.DriverRepositorySource{ + ID: config.DefaultDriverRepositoryBetaID, ManifestURL: betaURL + "/manifest.json", Enabled: true, + AllowInsecure: true, TrustedKeys: map[string]string{"test": betaKey}, + } + mustRefreshAll(t, manager) + versions, err := manager.AvailableVersions("demo") + if err != nil { + t.Fatal(err) + } + if len(versions) != 2 || versions[0].Driver.Version != "1.1.0-beta.1" || versions[0].Channel != "beta" || + versions[1].Driver.Version != "1.0.0" || versions[1].Channel != "stable" { + t.Fatalf("versions = %+v; want beta 1.1.0-beta.1 then stable 1.0.0", versions) + } + + // Promotion publishes the same file on both channels; it is one version. + beta.setVersion(betaURL, "1.0.0") + mustRefreshAll(t, manager) + if versions, _ = manager.AvailableVersions("demo"); len(versions) != 1 || versions[0].Channel != "stable" { + t.Fatalf("versions = %+v; want the promoted file listed once, as stable", versions) + } + + // A beta version that stable has already passed is not offered. + beta.setVersion(betaURL, "0.9.0") + mustRefreshAll(t, manager) + if versions, _ = manager.AvailableVersions("demo"); len(versions) != 1 || versions[0].Driver.Version != "1.0.0" { + t.Fatalf("versions = %+v; want only stable 1.0.0", versions) + } +} + +// A beta file stable later publishes byte for byte is still the file that +// runs; the owner's choice must not vanish from the list. +func TestRunningBetaFileStaysRunningWhenStablePublishesIt(t *testing.T) { + stable, stableURL, stableKey := signedServer(t, "1.0.0") + _, betaURL, betaKey := signedServer(t, "1.1.0") + dir := t.TempDir() + store, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + manager := New(&config.DeviceRepository{Enabled: true, Repositories: []config.DriverRepositorySource{{ + ID: "stable", ManifestURL: stableURL + "/manifest.json", Enabled: true, AllowInsecure: true, + TrustedKeys: map[string]string{"test": stableKey}, + }}}, dir, store) + manager.betaRepo = config.DriverRepositorySource{ + ID: config.DefaultDriverRepositoryBetaID, ManifestURL: betaURL + "/manifest.json", Enabled: true, + AllowInsecure: true, TrustedKeys: map[string]string{"test": betaKey}, + } + mustRefreshAll(t, manager) + if _, err := manager.InstallChannel(context.Background(), "beta", "demo", "1.1.0"); err != nil { + t.Fatal(err) + } + + stable.setVersion(stableURL, "1.1.0") // promotion: the same bytes on stable + mustRefreshAll(t, manager) + versions, err := manager.AvailableVersions("demo") + if err != nil { + t.Fatal(err) + } + for _, v := range versions { + if v.Driver.Version == "1.1.0" { + if v.Installed == nil || !v.Installed.Active { + t.Fatalf("1.1.0 row = %+v; the beta install that runs must mark it", v) + } + return + } + } + t.Fatalf("no 1.1.0 row in %+v", versions) +} + +// "Check for new versions" still redraws when beta cannot be reached. +func TestUnreachableBetaIsAWarningNotAFailure(t *testing.T) { + _, stableURL, stableKey := signedServer(t, "1.0.0") + dir := t.TempDir() + store, err := state.Open(filepath.Join(dir, "state.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + manager := New(&config.DeviceRepository{Enabled: true, Repositories: []config.DriverRepositorySource{{ + ID: "stable", ManifestURL: stableURL + "/manifest.json", Enabled: true, AllowInsecure: true, + TrustedKeys: map[string]string{"test": stableKey}, + }}}, dir, store) + manager.betaRepo = config.DriverRepositorySource{ + ID: config.DefaultDriverRepositoryBetaID, ManifestURL: "http://127.0.0.1:1/manifest.json", Enabled: true, AllowInsecure: true, + } + warnings, err := manager.RefreshAll(context.Background()) + if err != nil || len(warnings) != 1 { + t.Fatalf("RefreshAll = %v, %v; want stable refreshed and one beta warning", warnings, err) + } + if versions, _ := manager.AvailableVersions("demo"); len(versions) != 1 || versions[0].Driver.Version != "1.0.0" { + t.Fatalf("versions = %+v; stable must still be listed", versions) + } +} diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go index 903129081..64e0016b5 100644 --- a/go/internal/drivers/catalog.go +++ b/go/internal/drivers/catalog.go @@ -44,6 +44,9 @@ type CatalogEntry struct { // ReleaseVersion is the version this Core release bundles at the same // path, set on an entry that overrides it. ReleaseVersion string `json:"release_version,omitempty"` + // Chosen marks a managed entry the owner picked over a newer release + // copy; it stays across Core updates until the owner changes it. + Chosen bool `json:"chosen,omitempty"` // AuthPostPath is the URL path a read-only driver signs in at. A driver // that reads a vendor cloud has to POST for a token before it can read, // and that POST is not actuation. Only meaningful with ReadOnly. diff --git a/go/internal/ftwcli/cli_test.go b/go/internal/ftwcli/cli_test.go index 30d82d219..b6674583e 100644 --- a/go/internal/ftwcli/cli_test.go +++ b/go/internal/ftwcli/cli_test.go @@ -356,13 +356,15 @@ func TestStatusShowsReleaseLastRunAndHealth(t *testing.T) { } f.on("GET", "/api/drivers/catalog", reply(200, `{"entries":[ {"version":"1.5.8","source":"bundled","used_by":["sungrow"]}, - {"version":"1.3.2","source":"managed","used_by":["easee"],"release_version":"1.3.3"}, + {"version":"1.3.2","source":"managed","used_by":["easee"],"release_version":"1.3.3","chosen":true}, + {"version":"2.2.0","source":"managed","used_by":["battery"],"release_version":"2.1.9"}, {"version":"0.1.0","source":"local","used_by":["meter"]}, {"version":"2.1.2","source":"bundled"}]}`)) _, out, _ = runCLI(t, testEnv(), "status", "--url", srv.URL) for _, want := range []string{ - "Drivers: easee 1.3.2, meter 0.1.0, sungrow 1.5.8\n", - "Override: easee 1.3.2 from the driver channel; the release has 1.3.3", + "Drivers: battery 2.2.0, easee 1.3.2, meter 0.1.0, sungrow 1.5.8\n", + "Override: easee 1.3.2, chosen and kept across updates; the release has 1.3.3", + "Override: battery 2.2.0 from the driver channel until a release has it; the release has 2.1.9", "Override: meter runs a local file; the release has no copy", } { if !strings.Contains(out, want) { diff --git a/go/internal/ftwcli/status.go b/go/internal/ftwcli/status.go index ca47b047c..23080807b 100644 --- a/go/internal/ftwcli/status.go +++ b/go/internal/ftwcli/status.go @@ -226,6 +226,7 @@ func (c *client) printDrivers(ctx context.Context, out io.Writer) { Source string `json:"source"` UsedBy []string `json:"used_by"` ReleaseVersion string `json:"release_version"` + Chosen bool `json:"chosen"` } `json:"entries"` } if err := c.get(ctx, "/api/drivers/catalog", &catalog); err != nil { @@ -239,10 +240,14 @@ func (c *client) printDrivers(ctx context.Context, out io.Writer) { if e.ReleaseVersion != "" { release = "the release has " + e.ReleaseVersion } - switch e.Source { - case "managed": + switch { + case e.Source == "managed" && e.Chosen: + overrides = append(overrides, fmt.Sprintf("%s %s, chosen and kept across updates; %s", name, orUnknown(e.Version), release)) + case e.Source == "managed" && e.ReleaseVersion != "": + overrides = append(overrides, fmt.Sprintf("%s %s from the driver channel until a release has it; %s", name, orUnknown(e.Version), release)) + case e.Source == "managed": overrides = append(overrides, fmt.Sprintf("%s %s from the driver channel; %s", name, orUnknown(e.Version), release)) - case "local": + case e.Source == "local": overrides = append(overrides, fmt.Sprintf("%s runs a local file; %s", name, release)) } } diff --git a/web/app.css b/web/app.css index 30ca004af..3c3b102a4 100644 --- a/web/app.css +++ b/web/app.css @@ -2053,6 +2053,12 @@ body.ftw-app .drv-version-detail { color: var(--fg-muted); font-size: 0.75rem; } +/* What changed in a version: a quiet link beside the prose, not a button. */ +body.ftw-app .drv-version-changes { + color: var(--accent-e, var(--fg-muted)); + font-size: 0.75rem; + white-space: nowrap; +} /* The driver's Lua. Scrolls in its own box rather than stretching the modal, and keeps the operator's own indentation. */ body.ftw-app .drv-source-code { diff --git a/web/driver-source-suggest.test.mjs b/web/driver-source-suggest.test.mjs index c6917fc15..dd9aad184 100644 --- a/web/driver-source-suggest.test.mjs +++ b/web/driver-source-suggest.test.mjs @@ -98,7 +98,7 @@ test("the issue carries what someone needs to act without asking", () => { // hash it was based on — a maintainer can find the exact bytes from that. assert.match(body, /sungrow/); assert.match(body, /v1\.5\.0/); - assert.match(body, /official/); + assert.match(body, /Came from: from the driver channel/); assert.match(body, /abc123def456/); }); @@ -167,7 +167,7 @@ test("opening the editor hands it the driver and the actions it needs", () => { assert.equal(driver.lua, SOURCE_BODY.lua); // Provenance is resolved here, so the editor does not need to know the // three overlays exist. - assert.match(driver.sourceLabel, /official/); + assert.equal(driver.sourceLabel, "from the driver channel"); for (const name of ["runDraft", "keepDraft", "revertDraft", "draftStatus", "lint", "suggest"]) { assert.equal(typeof actions[name], "function", `actions.${name} is missing`); } diff --git a/web/driver-update-list.test.mjs b/web/driver-update-list.test.mjs index f7c307bc9..f8e434160 100644 --- a/web/driver-update-list.test.mjs +++ b/web/driver-update-list.test.mjs @@ -1,45 +1,50 @@ +// One place for driver versions: the device's Versions panel under Settings › +// Devices. Update Center and System only point there, and adding a device +// chooses a driver type, not a version. + import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; const badge = readFileSync(new URL("./update-badge.js", import.meta.url), "utf8"); const devices = readFileSync(new URL("./settings/tabs/devices.js", import.meta.url), "utf8"); +const system = readFileSync(new URL("./settings/tabs/system.js", import.meta.url), "utf8"); + +test("Update Center lists no driver versions and offers no driver actions", () => { + assert.doesNotMatch(badge, /device_repository/); + assert.doesNotMatch(badge, /driver-change|driver-versions|_driverCatalog|_refreshDriverCatalog/); + assert.match(badge, /Versions are chosen per device under Settings › Devices\./); +}); -test("the dialog lists every configured driver, not just the updatable ones", () => { - assert.match(badge, /apiFetch\("\/api\/drivers\/catalog"\)/); - assert.match(badge, /apiFetch\("\/api\/config"\)/); - assert.match(badge, /device_repository\/catalog\?channel=beta/); - assert.match(badge, /configured\.has\(driverFileKey/); - assert.match(badge, /pending_update: stableAvailable \|\| betaAvailable/); - // A driver with nothing waiting still gets a row, so the inventory does - // not change shape depending on what happens to be releasable today. - assert.doesNotMatch(badge, /return stableAvailable \|\| betaAvailable;/); - assert.doesNotMatch(badge, /if \(entry\.source === "local"\) return false;/); - assert.doesNotMatch(badge, /No configured drivers found/); - assert.doesNotMatch(badge, /apiFetch\("\/api\/device_repository\/catalog"\)/); - assert.doesNotMatch(badge, /No managed driver candidates cached yet/); +test("the badge counts only Core, so a new driver does not light it up", () => { + assert.match(badge, /return \{ core, total: core \? 1 : 0 \};/); + assert.match(badge, /showDot = pending\.total > 0/); }); -test("a locally edited driver is listed but offered no signed action", () => { - assert.match(badge, /const managed = entry\.source !== "local"/); - assert.match(badge, /title="Edited on this device; no signed version to switch to">local copy); - assert.match(badge, /managed && entry\.update_available/); - assert.match(badge, /managed && betaDriver && betaDriver\.version/); +test("System points to Devices instead of refreshing driver catalogs itself", () => { + assert.doesNotMatch(system, /sys-refresh-drivers|device_repository\/refresh/); + assert.match(system, /versions under Devices/); }); -test("Update Center can install one signed beta driver without a Core update", () => { - assert.match(badge, /"Beta " \+ escapeHTML\(betaDriver\.version\)/); - assert.match(badge, /data-channel="beta"/); - assert.match(badge, /channel \? \{ channel \} : \{\}/); - assert.match(badge, /Only affected driver instances restart/); +test("a device card announces no update; Versions is the way in", () => { + assert.doesNotMatch(devices, /drv-module-update|Update to v/); + assert.match(devices, /class="btn-add drv-module-versions"/); }); -test("Devices can add one driver straight from the signed beta channel", () => { - assert.match(devices, /id="driver-catalog-channel"/); - assert.match(devices, /Beta · test one driver/); - assert.match(devices, /device_repository\/catalog\?channel=beta/); - assert.match(devices, /JSON\.stringify\(\{channel: "beta", version: chosen\.dataset\.version\}\)/); - assert.match(devices, /Beta installs only the selected signed driver/); +test("adding a device lists channel driver types in the same list, marked by origin", () => { + assert.doesNotMatch(devices, /driver-catalog-channel/); + assert.match(devices, /id="driver-catalog-more"/); + // Both signed channels: stable has drivers the release does not carry. + assert.match(devices, /fetchCatalog\("\/api\/device_repository\/catalog"\)/); + assert.match(devices, /fetchCatalog\("\/api\/device_repository\/catalog\?channel=beta"\)/); + // One channel being unreachable does not hide what the other lists. + assert.match(devices, /Promise\.allSettled\(\[/); + assert.match(devices, /e\.channel === "beta" \? "beta" : "from the driver channel"/); + // The release's own drivers are added as they are; a channel driver is + // fetched from its channel when the device is added. + assert.match(devices, /populateCatalogPicker\(entries, "release"\)/); + assert.match(devices, /\? \{channel: "beta", version: chosen\.dataset\.version\}/); + assert.match(devices, /: \{repository_id: chosen\.dataset\.repositoryId, version: chosen\.dataset\.version\}/); }); test("Devices configure the GoodWe register profile without editing YAML", () => { @@ -53,24 +58,6 @@ test("Devices configure the GoodWe register profile without editing YAML", () => assert.match(devices, /unit_id = selectedProfile\.unitId/); }); -test("Update Center only offers stable or beta when that signed version differs", () => { - assert.match(badge, /entry\.update_available && entry\.repository_id && entry\.upstream_version/); - assert.match(badge, /"Stable " \+ escapeHTML\(entry\.upstream_version\)/); - assert.match(badge, /isNewerVersion\(betaDriver\.version, current\)/); - assert.doesNotMatch(badge, />current<\/span>/); - assert.doesNotMatch(badge, /entry\.update_available \|\| !entry\.installed/); - assert.doesNotMatch(badge, /\? "Update" : "Install"/); -}); - -test("the badge counts only drivers with work waiting, not the whole inventory", () => { - assert.match(badge, /_pendingUpdates\(\)/); - assert.match(badge, /filter\(\(entry\) => entry\.pending_update\)\.length/); - assert.match(badge, /showDot = pending\.total > 0/); - // The old signal lit the dot for any listed driver, which now means all - // of them. - assert.doesNotMatch(badge, /this\._driverCatalog\.entries\.length > 0/); -}); - test("Devices links to repository support data without traffic-light claims", () => { assert.match(devices, /device-drivers\/blob\/main\/SUPPORT_STATUS\.md/); assert.doesNotMatch(devices, /production — verified on real hardware/); @@ -78,17 +65,3 @@ test("Devices links to repository support data without traffic-light claims", () assert.doesNotMatch(devices, /ported from reference/); assert.doesNotMatch(devices, /[🟢🟡🔴]/u); }); - -test("a beta driver counts as an update only when it is newer than what runs", () => { - const source = badge.match(/ function isNewerVersion\([\s\S]*?\n }\n/)[0]; - const isNewerVersion = new Function(source + "\nreturn isNewerVersion;")(); - assert.equal(isNewerVersion("1.3.3", "1.3.2"), true); - assert.equal(isNewerVersion("1.3.2", "1.3.3"), false); - assert.equal(isNewerVersion("1.3.3", "1.3.3"), false); - assert.equal(isNewerVersion("1.4.0-beta.1", "1.3.3"), true); - assert.equal(isNewerVersion("1.4.0", "1.4.0-beta.1"), true); - assert.equal(isNewerVersion("1.4.0-beta.1", "1.4.0"), false); - assert.equal(isNewerVersion("1.4.0-beta.2", "1.4.0-beta.10"), false); - assert.equal(isNewerVersion("1.0.0", ""), true); - assert.equal(isNewerVersion("", "1.0.0"), false); -}); diff --git a/web/driver-versions.test.mjs b/web/driver-versions.test.mjs index 8b4aa3fdc..cc7d06350 100644 --- a/web/driver-versions.test.mjs +++ b/web/driver-versions.test.mjs @@ -67,6 +67,16 @@ function buttonsOf(el) { return el.children.flatMap((c) => (c.tag === "button" ? [c] : buttonsOf(c))); } +// The panel's rows are told apart by what they are, not by their position: +// the release's own copy comes first and "Check for new versions" last. +function rowOf(panel, key) { + return panel.children.find((c) => c.dataset && c.dataset.row === key); +} + +function switchButtons(panel) { + return buttonsOf(panel).filter((b) => b.textContent !== "Check for new versions"); +} + // What /api/drivers/catalog will say after the switch under test. let catalogEntry = {}; @@ -244,7 +254,7 @@ test("undo goes to the bundled copy when that is what was running", async () => await settle(); const undo = buttonsOf(panel).find((b) => b.textContent.startsWith("Undo")); assert.ok(undo, "installing over the bundled driver is the first thing anyone does"); - assert.match(undo.textContent, /bundled driver/); + assert.match(undo.textContent, /this release's driver/); undo.click(); await settle(); @@ -296,7 +306,7 @@ test("switching rewrites the summary line from the catalog, not from the old tex await settle(); assert.equal(headlineEl.textContent, "v1.1.1"); - assert.equal(detailEl.textContent, "official · untested"); + assert.equal(detailEl.textContent, "from the driver channel · untested"); assert.equal(readOnlyEl.style.display, "", "1.1.1 may only read"); }); @@ -327,46 +337,23 @@ test("undo rewrites the line back to what is running again", async () => { await settle(); assert.equal(headlineEl.textContent, "v1.0.0"); - assert.match(detailEl.textContent, /shipped with this build/); + assert.match(detailEl.textContent, /this release/); assert.equal(readOnlyEl.style.display, "none", "the bundled driver may control again"); }); -test("the Update shortcut hides once its version is the one running", async () => { - const { api } = load(); - const panel = element("div"); - const headlineEl = element("span"); - const updateEl = element("button"); - updateEl.dataset.version = "1.1.1"; - - // No update left to offer once 1.1.1 is what runs. - catalogEntry = { - path: "drivers/ferroamp.lua", source: "managed", installed_version: "1.1.1", - update_available: false, - }; - api.render(panel, "ferroamp", PAYLOAD, { - runningVersion: "1.0.0", runningSource: "bundled", - logicalPath: "drivers/ferroamp.lua", headlineEl, updateEl, - }); - - buttonsOf(panel)[0].click(); - await settle(); - assert.equal(updateEl.style.display, "none", - "otherwise it offers to install what is already installed"); -}); - test("the version that is running is not offered as a switch target", () => { const { api } = load(); const panel = element("div"); api.render(panel, "ferroamp", PAYLOAD, { runningVersion: "1.0.0", runningSource: "bundled" }); - const labels = buttonsOf(panel).map((b) => b.textContent); + const labels = switchButtons(panel).map((b) => b.textContent); assert.equal(labels.join(" "), "Use this", "only 1.1.1 is a switch; 1.0.0 already runs"); }); -test("a managed driver can always get back to the bundled copy", () => { +test("a managed driver can always get back to the release's copy", () => { const { api } = load(); const panel = element("div"); - api.render(panel, "ferroamp", PAYLOAD, { + api.render(panel, "ferroamp", { ...PAYLOAD, release_version: "1.0.2" }, { runningVersion: "1.1.1", runningSource: "managed", logicalPath: "drivers/ferroamp.lua", }); @@ -374,26 +361,38 @@ test("a managed driver can always get back to the bundled copy", () => { // install and appears in neither. Without a row for it, an operator who // installed one channel version over a bundled driver and then closed this // panel has no way back at all. - assert.match(textOf(panel), /the copy shipped with this build/); + const release = rowOf(panel, "release"); + assert.ok(release, "the release's copy has its own row"); + assert.equal(panel.children[0], release, "and it comes first"); + assert.match(textOf(release), /v1\.0\.2 this release/); + assert.equal(buttonsOf(release).map((b) => b.textContent).join(" "), "Use this"); }); -test("the bundled row is not offered when the bundled copy is already running", () => { +test("the release's row runs without a switch when its copy is running", () => { const { api } = load(); const panel = element("div"); - api.render(panel, "ferroamp", PAYLOAD, { runningVersion: "1.0.0", runningSource: "bundled" }); + api.render(panel, "ferroamp", { ...PAYLOAD, release_version: "1.0.2" }, { runningVersion: "1.0.2", runningSource: "bundled" }); + + const release = rowOf(panel, "release"); + assert.match(textOf(release), /running now · this release/); + assert.equal(buttonsOf(release).length, 0, "switching to what is already running is not a choice"); +}); - assert.ok(!/shipped with this build/.test(textOf(panel)), - "switching to what is already running is not a choice"); +test("a driver the release does not carry gets no release row", () => { + const { api } = load(); + const panel = element("div"); + api.render(panel, "ferroamp", PAYLOAD, { runningVersion: "1.1.1", runningSource: "managed" }); + assert.equal(rowOf(panel, "release"), undefined, "use_bundled would refuse; do not offer it"); }); test("switching to the bundled copy uses its own endpoint, which refuses when there is none", async () => { const { api, calls } = load(); const panel = element("div"); - api.render(panel, "ferroamp", PAYLOAD, { + api.render(panel, "ferroamp", { ...PAYLOAD, release_version: "1.0.2" }, { runningVersion: "1.1.1", runningSource: "managed", logicalPath: "drivers/ferroamp.lua", }); - const bundled = buttonsOf(panel).at(-1); + const bundled = buttonsOf(rowOf(panel, "release"))[0]; bundled.click(); await settle(); @@ -432,18 +431,18 @@ test("switching to the bundled copy corrects the line above the panel", async () path: "drivers/ferroamp.lua", source: "bundled", version: "1.0.0", verification_status: "production", }; - api.render(panel, "ferroamp", PAYLOAD, { + api.render(panel, "ferroamp", { ...PAYLOAD, release_version: "1.0.0" }, { runningVersion: "1.1.1", runningSource: "managed", logicalPath: "drivers/ferroamp.lua", headlineEl, detailEl, }); - buttonsOf(panel).at(-1).click(); + buttonsOf(rowOf(panel, "release"))[0].click(); await settle(); // This is not an undo -- it can be the first thing done after opening the // panel, so there is no earlier line to restore. assert.equal(headlineEl.textContent, "v1.0.0"); - assert.match(detailEl.textContent, /shipped with this build/); + assert.match(detailEl.textContent, /this release/); }); test("an override downloads without claiming it will take over", async () => { @@ -454,7 +453,7 @@ test("an override downloads without claiming it will take over", async () => { assert.match(textOf(panel), /Your own file runs while it is there/, "say why nothing here changes what runs"); - const buttons = buttonsOf(panel); + const buttons = switchButtons(panel); assert.equal(buttons.map((b) => b.textContent).join(" "), "Download Downloaded", "an override shadows the channel, so 'Use this' would be a lie"); assert.equal(buttons[1].disabled, true, "already on disk, nothing to fetch"); @@ -480,13 +479,19 @@ test("what is running reads as words, not as an enum", () => { source: "managed", installed_version: "1.1.1", verification_status: "production", }); assert.equal(managed.headline, "v1.1.1"); - assert.equal(managed.detail, "official · verified on hardware"); + assert.equal(managed.detail, "from the driver channel · verified on hardware"); const bundled = api.runningSummary({ source: "bundled", version: "1.0.0", verification_status: "experimental", }); assert.equal(bundled.headline, "v1.0.0"); - assert.equal(bundled.detail, "official, shipped with this build · untested"); + assert.equal(bundled.detail, "this release · untested"); + + // Whether an override stays across a Core update is part of what it is. + const chosen = api.runningSummary({ source: "managed", version: "1.3.2", chosen: true, release_version: "1.3.3" }); + assert.equal(chosen.detail, "chosen, kept across updates · release has v1.3.3"); + const early = api.runningSummary({ source: "managed", version: "1.3.4", release_version: "1.3.3" }); + assert.equal(early.detail, "until a release has it · release has v1.3.3"); // An operator's own file has no version the channel would recognise, so // naming one would read as provenance it does not have. Point at the file @@ -498,24 +503,6 @@ test("what is running reads as words, not as an enum", () => { assert.equal(own.detail, "drivers/ferroamp.lua"); }); -test("an override is not offered an Update button", () => { - const { api } = load(); - - const managed = api.runningSummary({ - source: "managed", version: "1.0.0", update_available: true, - repository_id: "ftw-official", upstream_version: "1.1.1", - }); - assert.equal(managed.updatable, true); - - // Installing a channel version while a local file is present changes - // nothing: the local file still wins. - const overridden = api.runningSummary({ - source: "local", version: "local", update_available: true, - repository_id: "ftw-official", upstream_version: "1.1.1", - }); - assert.equal(overridden.updatable, false); -}); - test("manifest data and driver source become text, never markup", () => { const { api } = load(); // Everything between these two builds DOM from remote or operator-supplied @@ -577,3 +564,117 @@ test("a saved filename change tells the open Settings dialog to reload before Sa await settle(); assert.match(textOf(panel), /fresh telemetry verified. Settings changed. Close and reopen Settings before saving/); }); + +test("a channel file of the release's version is listed only while it runs", () => { + const { api } = load(); + const payload = { ...PAYLOAD, release_version: "1.1.1" }; + const rows = api.versionRows(payload); + assert.deepEqual([...rows].map((r) => r.version), ["1.0.0"], "the release's row stands for 1.1.1"); + + const running = api.versionRows({ + release_version: "1.0.0", installed: null, available: PAYLOAD.available, + }); + assert.deepEqual([...running].map((r) => r.version), ["1.1.1", "1.0.0"], + "a managed 1.0.0 that runs stays visible, or the panel hides what is running"); +}); + +test("a beta version is marked and installs through the beta channel", async () => { + const { api, calls } = load(); + const panel = element("div"); + api.render(panel, "ferroamp", { + installed: null, + available: [{ repository_id: "ftw-official-beta", channel: "beta", driver: { version: "1.2.0-beta.1", sha256: "bb22…" } }], + }, { runningVersion: "1.0.0", runningSource: "managed", logicalPath: "drivers/ferroamp.lua" }); + + const row = rowOf(panel, "v1.2.0-beta.1"); + assert.match(textOf(row), /beta/); + buttonsOf(row)[0].click(); + await settle(); + assert.equal(calls[0].path, "/api/device_repository/drivers/ferroamp/install"); + assert.deepEqual(calls[0].body, { version: "1.2.0-beta.1", channel: "beta" }); +}); + +test("the owner's choice is marked where the versions are", () => { + const { api } = load(); + const panel = element("div"); + api.render(panel, "ferroamp", { ...PAYLOAD, release_version: "1.1.1", chosen_version: "1.0.0" }, { + runningVersion: "1.0.0", runningSource: "managed", logicalPath: "drivers/ferroamp.lua", + }); + assert.match(textOf(rowOf(panel, "v1.0.0")), /chosen, kept across updates/); +}); + +test("checking for new versions refreshes both channels and redraws the list", async () => { + const refreshed = { ...PAYLOAD, available: [...PAYLOAD.available, + { repository_id: "ftw-official-beta", channel: "beta", driver: { version: "1.2.0-beta.1", sha256: "bb22…" } }] }; + const { api, calls } = load(refreshed); + const panel = element("div"); + api.render(panel, "ferroamp", PAYLOAD, { runningVersion: "1.0.0", runningSource: "managed", logicalPath: "drivers/ferroamp.lua" }); + + buttonsOf(panel).find((b) => b.textContent === "Check for new versions").click(); + await settle(); + await settle(); + assert.equal(calls[0].path, "/api/device_repository/refresh"); + assert.equal(calls[1].path, "/api/device_repository/drivers/ferroamp/versions"); + assert.ok(rowOf(panel, "v1.2.0-beta.1"), "the new beta row is drawn"); +}); + +test("each version links to what changed, and only to its GitHub source", () => { + const { api } = load(); + const panel = element("div"); + api.render(panel, "goodwe", { + logical_path: "drivers/goodwe.lua", + release_version: "2.1.2", + release_source: { repository: "https://github.com/srcfl/device-drivers", commit: "489c9373be1fb391d885b948bf736399b3e40f2e" }, + installed: null, + available: [ + { repository_id: "ftw-official", channel: "stable", repository: "https://github.com/srcfl/device-drivers", + driver: { version: "2.1.1", sha256: "0dc9…", filename: "goodwe.lua", source_commit: "f18ceef" } }, + { repository_id: "mine", channel: "stable", repository: "javascript:alert(1)", + driver: { version: "2.0.0", sha256: "ee00…", filename: "goodwe.lua", source_commit: "f18ceef" } }, + ], + }, { runningVersion: "2.1.2", runningSource: "bundled", logicalPath: "drivers/goodwe.lua" }); + + const linkOf = (row) => row.children.find((c) => c.tag === "a"); + assert.equal(linkOf(rowOf(panel, "release")).href, + "https://github.com/srcfl/device-drivers/commits/489c9373be1fb391d885b948bf736399b3e40f2e/drivers/lua/goodwe.lua"); + assert.equal(linkOf(rowOf(panel, "v2.1.1")).href, + "https://github.com/srcfl/device-drivers/commits/f18ceef/drivers/lua/goodwe.lua"); + assert.equal(linkOf(rowOf(panel, "v2.1.1")).textContent, "What changed"); + assert.equal(linkOf(rowOf(panel, "v2.0.0")), undefined, "a source that is not GitHub gets no link"); +}); + +test("after a switch, checking for new versions redraws what runs now", async () => { + // install, refresh and versions all answer with this body in the stub. + const body = { ...PAYLOAD, release_version: "1.0.0", runtime_verified: true, restarted_drivers: ["p1"] }; + const { api } = load(body); + catalogEntry = { path: "drivers/ferroamp.lua", source: "managed", installed_version: "1.1.1" }; + const panel = element("div"); + api.render(panel, "ferroamp", body, { + runningVersion: "1.0.0", runningSource: "bundled", logicalPath: "drivers/ferroamp.lua", + headlineEl: element("span"), detailEl: element("span"), + }); + assert.equal(buttonsOf(rowOf(panel, "release")).length, 0, "the release's copy runs at first"); + + buttonsOf(rowOf(panel, "v1.1.1"))[0].click(); + await settle(); + buttonsOf(panel).find((b) => b.textContent === "Check for new versions").click(); + await settle(); + await settle(); + + const release = rowOf(panel, "release"); + assert.doesNotMatch(textOf(release), /running now/, "1.1.1 runs now, not the release's copy"); + assert.equal(buttonsOf(release).map((b) => b.textContent).join(" "), "Use this", + "the way back to the release's copy must stay after a redraw"); +}); + +test("a beta outage is shown but the list still redraws", async () => { + const body = { ...PAYLOAD, warnings: ["beta channel: connection refused"] }; + const { api } = load(body); + const panel = element("div"); + api.render(panel, "ferroamp", PAYLOAD, { runningVersion: "1.0.0", runningSource: "managed", logicalPath: "drivers/ferroamp.lua" }); + buttonsOf(panel).find((b) => b.textContent === "Check for new versions").click(); + await settle(); + await settle(); + assert.match(textOf(panel), /Checked, but beta channel: connection refused/); + assert.ok(rowOf(panel, "v1.1.1"), "the stable rows are drawn again"); +}); diff --git a/web/header-status-marks.test.mjs b/web/header-status-marks.test.mjs index 152b276e4..2653d9c50 100644 --- a/web/header-status-marks.test.mjs +++ b/web/header-status-marks.test.mjs @@ -93,13 +93,12 @@ describe("header status marks", () => { ok: true, json: async () => ({ optimizer: { configured: true, healthy: false } }), }); - delayed.get("/api/drivers/catalog")({ ok: true, json: async () => ({ entries: [] }) }); - delayed.get("/api/config")({ ok: true, json: async () => ({ drivers: [] }) }); - delayed.get("/api/device_repository/catalog?channel=beta")({ ok: true, json: async () => ({ entries: [] }) }); await new Promise((resolve) => setImmediate(resolve)); assert.ok(badge._components, "the delayed component response should reach its success handler"); - assert.ok(badge._driverCatalog, "the delayed catalog response should reach its success handler"); + // Driver versions are chosen under Settings › Devices; the badge asks + // nothing about drivers. + assert.deepEqual([...delayed.keys()].sort(), ["/api/components"]); assert.equal(badge._shadow.innerHTML, ""); assert.equal(badge.hidden, true); }); diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index 1ca54ac43..481990e15 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -152,10 +152,19 @@ // Every driver runs locally; they are just fetched from different places. // So this describes where a file came from, not a mode the driver is in. - function originLabel(source) { + // The release's copy is the default. Anything else is an owner's choice, + // and whether it stays across a Core update is part of saying what it is. + function originLabel(entry) { + var source = entry.source || "bundled"; + var release = entry.release_version ? "release has v" + entry.release_version : ""; if (source === "local") return "your own file"; - if (source === "bundled") return "official, shipped with this build"; - return "official"; + if (source === "bundled") return "this release"; + if (entry.chosen) return ["chosen, kept across updates", release].filter(Boolean).join(" · "); + if (entry.release_version && entry.release_version === (entry.installed_version || entry.version)) { + return "from the driver channel · same as this release"; + } + if (release) return "until a release has it · " + release; + return "from the driver channel"; } // What is running, in one line. Kept apart from the DOM so the wording can @@ -167,26 +176,43 @@ // so naming one would read as provenance it does not have. Point at the // file instead, which is what they would edit or delete. var local = source === "local"; - var detail = [local ? entry.path || "" : originLabel(source), + var detail = [local ? entry.path || "" : originLabel(entry), verificationLabel(entry.verification_status)].filter(Boolean); return { source: source, headline: local ? "your own file" : "v" + (entry.installed_version || entry.version || "unknown"), - detail: detail.join(" · "), - // An override shadows whatever the channel offers, so installing a newer - // version does not change what runs. Offering Update there would be a - // lie the operator only discovers by debugging. - updatable: !!(entry.update_available && entry.repository_id && !local), - upstreamVersion: entry.upstream_version || "" + detail: detail.join(" · ") }; } + // What changed in a version, as the driver file's history at the commit it + // was published from: the newest entries are the change. Only a GitHub + // source with a commit hash gives a page an owner can open. + function changesURL(repository, commit, filename) { + if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+$/.test(repository || "") || + !/^[0-9a-f]{7,40}$/.test(commit || "") || !filename) return ""; + return repository + "/commits/" + commit + "/drivers/lua/" + encodeURIComponent(filename); + } + + function appendChangesLink(line, url) { + if (!url) return; + var link = document.createElement("a"); + link.className = "drv-version-changes"; + link.href = url; + link.target = "_blank"; + link.rel = "noopener"; + link.textContent = "What changed"; + line.appendChild(link); + } + // GET /versions answers with VersionCandidate: {repository_id, driver:{…}, // installed?}. The version lives on .driver, and whether a candidate is // already on disk is .installed -- not a string match against a second list. function versionRows(body) { var installed = (body && body.installed) || []; var available = (body && body.available) || []; + var release = (body && body.release_version) || ""; + var chosen = (body && body.chosen_version) || ""; var rows = []; var seen = {}; @@ -212,8 +238,12 @@ // POST /install refuses a request without one; a version by itself // does not say which repository signed it. repositoryID: candidate.repository_id || "", + // Beta installs through the built-in channel, never by repository id. + channel: candidate.channel === "beta" ? "beta" : "stable", + changes: changesURL(candidate.repository, driver.source_commit, driver.filename), downloaded: !!onDisk, active: !!(onDisk && onDisk.active), + chosen: !!(onDisk && onDisk.active && chosen && driver.version === chosen), verification: verificationLabel((driver.metadata || {}).verification_status) }); }); @@ -227,13 +257,20 @@ version: item.version || "", sha256: item.sha256 || "", repositoryID: item.repo_id || "", + channel: "", + changes: "", downloaded: true, active: !!item.active, + chosen: !!(item.active && chosen && item.version === chosen), verification: "" }); }); - return rows; + // The release's own copy stands for its version. A channel file of the + // same version is the same driver, so it is listed only while it runs. + return rows.filter(function (row) { + return row.active || !release || row.version !== release; + }); } // The version list as its own surface, so a test can drive it with a real @@ -307,10 +344,13 @@ function renderVersionPicker(panel, driverID, body, opts) { var overridden = !!(opts && opts.overridden); var rows = versionRows(body); + var release = (body && body.release_version) || ""; + var releaseRuns = !!(opts && opts.runningSource === "bundled"); panel.textContent = ""; - if (rows.length === 0) { + if (rows.length === 0 && !release && !releaseRuns) { panel.textContent = "No versions found for this driver."; + renderCheckForVersions(panel, driverID, opts); return; } @@ -324,17 +364,73 @@ panel.appendChild(note); } + // The release's own copy comes first: it is what runs unless the owner + // picks something else, and the way back once they have. The bundled + // copy is not an install, so /versions never lists it as a row. + if (!overridden && (release || releaseRuns)) { + var pin = (body && body.release_source) || {}; + var file = String((body && body.logical_path) || "").split("/").pop(); + renderReleaseRow(panel, driverID, opts, release, releaseRuns, changesURL(pin.repository, pin.commit, file)); + } + rows.forEach(function (row) { renderVersionRow(panel, driverID, row, rows, opts, overridden); }); - // The bundled copy is not an install, so /versions never lists it and no - // amount of activating reaches it. Once a channel version is running it is - // the only thing left to go back to, and it has to stay reachable after - // this panel closes -- not just as an undo that lives for one switch. - if (!overridden && opts && opts.runningSource !== "bundled") { - renderBundledRow(panel, driverID, opts); - } + renderCheckForVersions(panel, driverID, opts); + } + + // Looking for newer versions is the owner's move, not a notification. It + // reads both signed channels and changes nothing that runs. + function renderCheckForVersions(panel, driverID, opts) { + var line = document.createElement("div"); + line.style.display = "flex"; + line.style.alignItems = "center"; + line.style.gap = "8px"; + line.style.marginTop = "8px"; + var check = document.createElement("button"); + check.type = "button"; + check.className = "btn-add drv-version-check"; + check.textContent = "Check for new versions"; + var status = document.createElement("span"); + status.className = "drv-version-status"; + var warnings = []; + check.addEventListener("click", function () { + check.disabled = true; + status.textContent = "Checking the signed channels…"; + apiFetch("/api/device_repository/refresh", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: "{}" + }).then(function (r) { + return r.json().then(function (b) { + if (!r.ok) throw new Error(b.error || "could not check for versions"); + return b; + }); + }).then(function (refreshed) { + warnings = (refreshed && refreshed.warnings) || []; + return apiFetch("/api/device_repository/drivers/" + encodeURIComponent(driverID) + "/versions"); + }).then(function (r) { + return r.json().then(function (b) { + if (!r.ok) throw new Error(b.error || "could not list versions"); + return b; + }); + }).then(function (body) { + renderVersionPicker(panel, driverID, body, opts); + if (warnings.length) { + var note = document.createElement("div"); + note.className = "drv-version-detail"; + note.textContent = "Checked, but " + warnings.join("; "); + panel.appendChild(note); + } + }).catch(function (err) { + status.textContent = err.message; + check.disabled = false; + }); + }); + line.appendChild(check); + line.appendChild(status); + panel.appendChild(line); } function configReloadNote(body) { @@ -359,6 +455,7 @@ function renderVersionRow(panel, driverID, row, rows, opts, overridden) { var line = document.createElement("div"); + line.dataset.row = "v" + row.version; line.style.display = "flex"; line.style.alignItems = "center"; line.style.gap = "8px"; @@ -374,9 +471,12 @@ var facts = []; if (row.active && !overridden) facts.push("selected"); else if (row.downloaded) facts.push("on disk"); + if (row.channel === "beta") facts.push("beta"); + if (row.chosen) facts.push("chosen, kept across updates"); if (row.verification) facts.push(row.verification); detail.textContent = facts.join(" · "); line.appendChild(detail); + appendChangesLink(line, row.changes); var status = document.createElement("span"); status.className = "drv-version-status"; @@ -397,6 +497,8 @@ var endpoint = row.downloaded ? "/activate" : "/install"; var payload = row.downloaded ? {version: row.version, sha256: row.sha256 || ""} + : row.channel === "beta" + ? {version: row.version, channel: "beta"} : {version: row.version, repository_id: row.repositoryID || ""}; status.textContent = row.downloaded ? "Switching…" : "Fetching…"; apiFetch("/api/device_repository/drivers/" + encodeURIComponent(driverID) + endpoint, { @@ -449,11 +551,12 @@ panel.appendChild(line); } - // "Back to what shipped with this build" as a standing choice, not a - // transient undo. POST /use_bundled refuses when no bundled copy exists - // rather than stopping the driver it was meant to revert. - function renderBundledRow(panel, driverID, opts) { + // The release's own copy as a standing choice, not a transient undo. POST + // /use_bundled refuses when no bundled copy exists rather than stopping the + // driver it was meant to revert. + function renderReleaseRow(panel, driverID, opts, release, running, changes) { var line = document.createElement("div"); + line.dataset.row = "release"; line.style.display = "flex"; line.style.alignItems = "center"; line.style.gap = "8px"; @@ -461,42 +564,45 @@ var label = document.createElement("span"); label.className = "creds-badge"; - label.textContent = "bundled"; + label.textContent = release ? "v" + release : "this release"; line.appendChild(label); var detail = document.createElement("span"); detail.className = "drv-version-detail"; - detail.textContent = "the copy shipped with this build"; + detail.textContent = running ? "running now · this release" : "this release"; line.appendChild(detail); + appendChangesLink(line, changes); var status = document.createElement("span"); status.className = "drv-version-status"; - var action = document.createElement("button"); - action.type = "button"; - action.className = "btn-add"; - action.textContent = "Use this"; - action.addEventListener("click", function () { - action.disabled = true; - status.textContent = "Switching…"; - useBundled(driverID, opts).then(function (body) { - if (!runtimeVerified(body, opts)) { - status.textContent = "Bundled driver selected. No running instance was verified for this device." + configReloadNote(body); + if (!running) { + var action = document.createElement("button"); + action.type = "button"; + action.className = "btn-add"; + action.textContent = "Use this"; + action.addEventListener("click", function () { + action.disabled = true; + status.textContent = "Switching…"; + useBundled(driverID, opts).then(function (body) { + if (!runtimeVerified(body, opts)) { + status.textContent = "The release's driver is selected. No running instance was verified for this device." + configReloadNote(body); + action.disabled = false; + return; + } + applyRuntimeResult(opts, body); + status.textContent = "The release's driver is running." + configReloadNote(body); + refreshSummary(opts, driverID); + markRunning(panel, null); + action.textContent = "Running"; + }).catch(function (err) { + status.textContent = err.message; action.disabled = false; - return; - } - applyRuntimeResult(opts, body); - status.textContent = "The bundled driver is running." + configReloadNote(body); - refreshSummary(opts, driverID); - markRunning(panel, null); - action.textContent = "Running"; - }).catch(function (err) { - status.textContent = err.message; - action.disabled = false; + }); }); - }); + line.appendChild(action); + } - line.appendChild(action); line.appendChild(status); panel.appendChild(line); } @@ -535,20 +641,19 @@ entries.forEach(function (e) { if (e && e.path === opts.logicalPath) entry = e; }); if (!entry) return; var summary = runningSummary(entry); + // The picker is redrawn from these options (Check for new versions), + // so they follow what runs now, not what ran when it was opened. + opts.runningSource = entry.source || "bundled"; + opts.runningVersion = entry.installed_version || entry.version || ""; + opts.overridden = opts.runningSource === "local"; if (opts.versionsEl) { opts.versionsEl.dataset.logicalPath = entry.path; - opts.versionsEl.dataset.source = entry.source || "bundled"; - opts.versionsEl.dataset.runningVersion = entry.installed_version || entry.version || ""; + opts.versionsEl.dataset.source = opts.runningSource; + opts.versionsEl.dataset.runningVersion = opts.runningVersion; } badge.textContent = summary.headline; if (opts.detailEl) opts.detailEl.textContent = summary.detail; if (opts.readOnlyEl) opts.readOnlyEl.style.display = entry.read_only ? "" : "none"; - // The Update shortcut offers one specific version; once that version - // is running it is an invitation to install what is already installed. - if (opts.updateEl) { - opts.updateEl.style.display = summary.updatable && - opts.updateEl.dataset.version === entry.upstream_version ? "" : "none"; - } }) .catch(function () { /* the switch itself already reported its result */ }); } @@ -559,15 +664,16 @@ // from it. function markRunning(panel, version) { if (!panel) return; - var wanted = version === null ? "bundled" : "v" + version; + // Rows are told apart by what they are, not by their label: the release's + // copy and a channel file can carry the same version. + var wanted = version === null ? "release" : "v" + version; Array.prototype.forEach.call(panel.children, function (line) { - var label = line.querySelector ? line.querySelector(".creds-badge") : null; var detail = line.querySelector ? line.querySelector(".drv-version-detail") : null; - if (!label || !detail) return; + if (!detail || !line.dataset || !line.dataset.row) return; var facts = detail.textContent.split(" · ").filter(function (fact) { return fact !== "running now" && fact !== "selected"; }); - if (label.textContent === wanted) facts.unshift("running now"); + if (line.dataset.row === wanted) facts.unshift("running now"); detail.textContent = facts.join(" · "); }); } @@ -592,7 +698,7 @@ var detail = document.createElement("span"); detail.className = "drv-version-detail"; - detail.textContent = [originLabel(body.source), body.filename, describeSize(body.bytes)] + detail.textContent = [originLabel(body), body.filename, describeSize(body.bytes)] .filter(Boolean).join(" · "); header.appendChild(detail); @@ -684,7 +790,7 @@ lua: body.lua, sha256: body.sha256, source: body.source, - sourceLabel: originLabel(body.source), + sourceLabel: originLabel(body), repository_url: body.repository_url }, { runDraft: function (lua, minutes) { return post("/draft", {lua: lua, minutes: minutes}); }, @@ -834,7 +940,7 @@ "", "---", "Driver: " + (body.id || "") + " " + (body.version ? "v" + body.version : ""), - "Came from: " + originLabel(body.source), + "Came from: " + originLabel(body), "File: " + (body.filename || ""), "Original sha256: " + (body.sha256 || "") ]; @@ -900,7 +1006,7 @@ undo.type = "button"; undo.className = "btn-add"; undo.textContent = wasBundled - ? "Undo (back to the bundled driver)" + ? "Undo (back to this release's driver)" : "Undo (back to v" + previousVersion + ")"; undo.addEventListener("click", function () { undo.disabled = true; @@ -916,7 +1022,7 @@ } applyRuntimeResult(opts, body); status.textContent = (wasBundled - ? "The bundled driver is running again." + ? "The release's driver is running again." : "v" + previousVersion + " is running again.") + configReloadNote(body); refreshSummary(opts, driverID); markRunning(panel, wasBundled ? null : previousVersion); @@ -970,9 +1076,6 @@ '
' : ''; html += '