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.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 += '
' + (S.chargerSetup ? 'Choose your charger' : 'Add from catalog') + '' + '
' + - '' + - '' + - '
' + // You know your hardware, not which driver covers it. The catalog // carries tested_models for most drivers, so searching those is what // turns "I have an SH10RT" into the right answer. @@ -983,6 +1086,11 @@ // the cards are what an operator actually looks at. '' + '
Loading catalog…
' + + // New driver types reach the beta channel first. Fetching them is the + // owner's move; they join the same list, marked, and nothing else + // changes until one is added. + '

' + + '

' + '
' + '' + '
' + '' + - '

Beta installs only the selected signed driver. Core and other drivers stay unchanged.

' + '

' + 'Driver support and hardware test status' + '

' + @@ -1267,7 +1374,6 @@ versionsEl: versions, headlineEl: slot.querySelector(".drv-module-headline"), detailEl: slot.querySelector(".drv-module-detail"), - updateEl: slot.querySelector(".drv-module-update"), readOnlyEl: slot.querySelector(".drv-module-readonly"), onPathChanged: function (previous, next) { config.drivers.forEach(function (driver, idx) { @@ -1382,7 +1488,10 @@ opt.dataset.capabilities = JSON.stringify(e.capabilities || []); opt.dataset.id = e.id || ""; opt.dataset.version = e.version || ""; - opt.dataset.channel = channel; + // "release" runs the bundled file; "stable" and "beta" are fetched + // from the signed channel when the device is added. + opt.dataset.channel = e.channel || channel || "release"; + opt.dataset.repositoryId = e.repository_id || ""; opt.dataset.httpHosts = (e.http_hosts || []).join(","); opt.dataset.connectionHost = (e.connection_defaults && e.connection_defaults.host) || ""; opt.dataset.connPort = (e.connection_defaults && e.connection_defaults.port) || ""; @@ -1440,6 +1549,12 @@ tag.textContent = cap; tags.appendChild(tag); }); + if (e.channel === "beta" || e.channel === "stable") { + var origin = document.createElement("span"); + origin.className = "drv-catalog-tag" + (e.channel === "beta" ? " drv-catalog-unproven" : ""); + origin.textContent = e.channel === "beta" ? "beta" : "from the driver channel"; + tags.appendChild(origin); + } var verdict = verificationLabel(e.verification_status); if (verdict) { var badge = document.createElement("span"); @@ -1499,21 +1614,9 @@ // between versions of the same driver. html += ' '; - if (summary.updatable) { - html += ' '; - } - // An override shadows the channel, so installing a newer version - // would not change what runs. Say that, rather than offering an - // Update button that appears to do nothing. - if (source === "local" && entry.upstream_version) { - html += ' ' + - 'official v' + escHtml(entry.upstream_version) + ' exists; your file keeps running'; - } - // One list, including for an override: seeing what else you could - // run is the whole point when you are testing your own driver. - // Rollback is gone -- stepping back is picking an older row. + // Versions is the one place a driver version is seen and changed. + // Nothing here announces a newer one: a new driver does not light + // up, the owner looks when they want to and picks. html += ' '; @@ -1527,30 +1630,6 @@ html += ''; slot.innerHTML = html; }); - bodyEl.querySelectorAll(".drv-module-update").forEach(function (btn) { - btn.addEventListener("click", function () { - btn.disabled = true; - var status = btn.parentElement.querySelector(".drv-module-action"); - if (status) status.textContent = " Validating and activating…"; - apiFetch("/api/device_repository/drivers/" + encodeURIComponent(btn.dataset.driverId) + "/install", { - method: "POST", headers: {"Content-Type":"application/json"}, - body: JSON.stringify({repository_id: btn.dataset.repositoryId, version: btn.dataset.version}) - }).then(function (r) { return r.json().then(function (body) { if (!r.ok) throw new Error(body.error || "install failed"); return body; }); }) - .then(function (body) { - var opts = moduleOptions(btn.parentElement); - if (!runtimeVerified(body, opts)) { - if (status) status.textContent = " Installed. No running instance was verified for this device." + configReloadNote(body); - btn.disabled = false; - return; - } - applyRuntimeResult(opts, body); - if (status) status.textContent = " Updated; fresh telemetry verified." + configReloadNote(body); - refreshSummary(opts, btn.dataset.driverId); - btn.remove(); - }) - .catch(function (err) { if (status) status.textContent = " " + err.message; btn.disabled = false; }); - }); - }); bodyEl.querySelectorAll(".drv-module-versions").forEach(function (btn) { btn.addEventListener("click", function () { var panel = btn.parentElement.querySelector(".drv-module-versions-panel"); @@ -1736,7 +1815,7 @@ var caps = (entry && entry.capabilities) || []; wrap.hidden = caps.indexOf("apicreds") < 0; }); - populateCatalogPicker(entries, "stable"); + populateCatalogPicker(entries, "release"); }); // Filtering happens over the catalog already fetched, so typing costs @@ -1746,40 +1825,68 @@ renderCatalogCards(S.catalogEntries || []); }); - var channelSelect = document.getElementById("driver-catalog-channel"); - if (channelSelect) channelSelect.addEventListener("change", function () { - if (channelSelect.value === "stable") { - apiFetch("/api/drivers/catalog").then(function (r) { return r.json(); }).then(function (data) { - populateCatalogPicker((data && data.entries) || [], "stable"); + // The release carries a set of drivers; the signed channel has more. + // Fetching them is the owner's move: they join the same list, marked + // by where they come from, and nothing changes until one is added. + // Versions of a driver already listed are not added here; those are + // chosen per device under Versions. + function channelEntries(data, channel) { + return ((data && data.entries) || []).map(function (candidate) { + var signed = (candidate && candidate.driver) || {}; + return Object.assign({}, signed.metadata || {}, { + id: signed.id, + path: signed.path, + filename: signed.filename, + version: signed.version, + read_only: signed.read_only, + channel: channel, + repository_id: candidate.repository_id || "" }); - return; - } - var sel = document.getElementById("driver-catalog-picker"); - if (sel) sel.innerHTML = ""; - apiFetch("/api/device_repository/catalog?channel=beta") - .then(function (r) { return r.json().then(function (body) { if (!r.ok) throw new Error(body.error || "beta catalog failed"); return body; }); }) - .then(function (data) { - var entries = ((data && data.entries) || []).map(function (candidate) { - var signed = (candidate && candidate.driver) || {}; - return Object.assign({}, signed.metadata || {}, { - id: signed.id, - path: signed.path, - filename: signed.filename, - version: signed.version, - read_only: signed.read_only - }); - }); - populateCatalogPicker(entries, "beta"); - }) - .catch(function (err) { - if (sel) { - sel.innerHTML = ""; - var opt = document.createElement("option"); - opt.value = ""; - opt.textContent = err.message; - sel.appendChild(opt); - } + }); + } + function fetchCatalog(path) { + return apiFetch(path).then(function (r) { + return r.json().then(function (body) { if (!r.ok) throw new Error(body.error || "driver catalog failed"); return body; }); + }); + } + var moreDrivers = document.getElementById("driver-catalog-more"); + if (moreDrivers) moreDrivers.addEventListener("click", function () { + var status = document.getElementById("driver-catalog-more-status"); + moreDrivers.disabled = true; + if (status) status.textContent = "Checking the signed channels…"; + // Either channel can be unreachable; what the other one lists still + // counts. + Promise.allSettled([ + fetchCatalog("/api/device_repository/catalog"), + fetchCatalog("/api/device_repository/catalog?channel=beta") + ]).then(function (settled) { + var failed = settled.filter(function (r) { return r.status !== "fulfilled"; }); + if (failed.length === settled.length) throw failed[0].reason; + var results = settled.map(function (r) { return r.status === "fulfilled" ? r.value : null; }); + var listed = S.catalogEntries || []; + var known = {}; + listed.forEach(function (e) { known[e.id] = true; known[e.filename] = true; }); + var added = []; + channelEntries(results[0], "stable").concat(channelEntries(results[1], "beta")).forEach(function (e) { + if (!e.id || known[e.id] || known[e.filename]) return; + known[e.id] = true; + known[e.filename] = true; + added.push(e); }); + populateCatalogPicker(listed.concat(added), "release"); + var beta = added.filter(function (e) { return e.channel === "beta"; }).length; + var parts = []; + if (added.length > beta) parts.push((added.length - beta) + " from the driver channel"); + if (beta) parts.push(beta + " in testing, marked beta"); + var note = failed.length ? " " + (results[1] ? "The driver channel" : "The beta channel") + + " could not be reached." : ""; + if (status) status.textContent = (added.length === 0 + ? "The release already has every signed driver." + : "Added " + parts.join(" and ") + ".") + note; + }).catch(function (err) { + if (status) status.textContent = err.message; + moreDrivers.disabled = false; + }); }); var catalogPicker = document.getElementById("driver-catalog-picker"); @@ -1871,19 +1978,22 @@ ctx.renderTab("devices"); revealAddedDevice(config.drivers.length - 1); }; - if (chosen.dataset.channel !== "beta") { + var fromChannel = chosen.dataset.channel === "beta" || chosen.dataset.channel === "stable"; + if (!fromChannel) { finishAdd(); return; } btn.disabled = true; - btn.textContent = "Installing signed beta…"; + btn.textContent = "Fetching the signed driver…"; apiFetch("/api/device_repository/drivers/" + encodeURIComponent(chosen.dataset.id) + "/install", { method: "POST", headers: {"Content-Type":"application/json"}, - body: JSON.stringify({channel: "beta", version: chosen.dataset.version}) + body: JSON.stringify(chosen.dataset.channel === "beta" + ? {channel: "beta", version: chosen.dataset.version} + : {repository_id: chosen.dataset.repositoryId, version: chosen.dataset.version}) }).then(function (r) { - return r.json().then(function (body) { if (!r.ok) throw new Error(body.error || "beta install failed"); return body; }); + return r.json().then(function (body) { if (!r.ok) throw new Error(body.error || "driver install failed"); return body; }); }).then(finishAdd).catch(function (err) { - window.alert("Beta driver install failed: " + err.message); + window.alert("Driver install failed: " + err.message); btn.disabled = false; btn.textContent = "+ Add selected"; }); @@ -1892,8 +2002,6 @@ var continueCharging = document.getElementById('charger-setup-continue'); if (continueCharging) { bodyEl.querySelectorAll('.device-meta,.driver-module-status,.device-core-row').forEach(function (element) { element.hidden = true; element.style.display = 'none'; }); - var channel = document.getElementById('driver-catalog-channel'); - if (channel) channel.parentElement.hidden = true; var name = document.getElementById('driver-catalog-name'); if (name) name.placeholder = 'e.g. garage'; var picker = document.getElementById('driver-catalog-picker'); diff --git a/web/settings/tabs/system.js b/web/settings/tabs/system.js index 4a1d209d9..aab19d48d 100644 --- a/web/settings/tabs/system.js +++ b/web/settings/tabs/system.js @@ -293,34 +293,21 @@ var driversHTML = '
Drivershost API ' + escHtml(drivers.driver_host_api || drivers.host_api || 1) + ' · ' + active + - ' managed
'; - var actionHTML = '
'; + ' managedversions under Devices
'; if (bundled) { el.innerHTML = '
FTW' + escHtml(bundled.ftwVersion) + 'bundled
' + warningHTML + driversHTML + - '
' + escHtml(bundled.note) + '
' + - actionHTML; + '
' + escHtml(bundled.note) + '
'; } else { el.innerHTML = '
Core' + escHtml(core.version || "dev") + ' · ' + escHtml(release.channel || "native") + 'safety
' + warningHTML + - driversHTML + - actionHTML; + driversHTML; } - var status = document.getElementById("sys-component-action"); - var driverBtn = document.getElementById("sys-refresh-drivers"); - if (driverBtn) driverBtn.onclick = function () { - driverBtn.disabled = true; - if (status) status.textContent = "Refreshing signed driver manifests…"; - apiFetch("/api/device_repository/refresh", {method:"POST", headers:{"Content-Type":"application/json"}, body:"{}"}) - .then(function (r) { return r.json().then(function (body) { if (!r.ok) throw new Error(body.error || "refresh failed"); return body; }); }) - .then(function () { if (status) status.textContent = "Driver catalog refreshed; no driver was activated."; driverBtn.disabled = false; }) - .catch(function (err) { if (status) status.textContent = err.message; driverBtn.disabled = false; }); - }; }).catch(function () { setText("sys-components", "Component status unavailable"); }); diff --git a/web/update-badge.js b/web/update-badge.js index ef826666f..eaac4a0e0 100644 --- a/web/update-badge.js +++ b/web/update-badge.js @@ -15,40 +15,6 @@ return fetch(path, opts); } - function driverFileKey(path) { - return String(path || "").replace(/\\/g, "/").split("/").pop().toLowerCase(); - } - - // A signed driver version is SemVer. A beta older than what runs is not an - // update: the badge counted one as waiting whenever the numbers differed. - function isNewerVersion(candidate, current) { - function parse(value) { - const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(String(value || "")); - return match ? { core: [+match[1], +match[2], +match[3]], pre: match[4] || "" } : null; - } - function comparePre(a, b) { - const left = a.split("."), right = b.split("."); - for (let i = 0; i < Math.min(left.length, right.length); i++) { - if (left[i] === right[i]) continue; - const ln = /^\d+$/.test(left[i]), rn = /^\d+$/.test(right[i]); - if (ln && rn) return +left[i] - +right[i]; - if (ln !== rn) return ln ? -1 : 1; - return left[i] < right[i] ? -1 : 1; - } - return left.length - right.length; - } - const a = parse(candidate), b = parse(current); - if (!a) return false; - if (!b) return true; - for (let i = 0; i < 3; i++) { - if (a.core[i] !== b.core[i]) return a.core[i] > b.core[i]; - } - if (a.pre === b.pre) return false; - if (!a.pre) return true; - if (!b.pre) return false; - return comparePre(a.pre, b.pre) > 0; - } - // Header status marks. Inline SVG rather than font glyphs: at 16px the // three announcements have to be separable by silhouette alone, because // colour is not reliable for every operator and the marks sit in the @@ -98,8 +64,6 @@ this._verifyingBackup = ""; this._components = null; this._componentHistory = null; - this._driverCatalog = null; - this._driverVersions = {}; this._componentAction = ""; this._connected = true; // header liveness light; see setConnected() this._bootHealth = null; @@ -114,11 +78,9 @@ this._resumeUpdateStatus(); this._refresh(false); this._refreshComponents(false); - this._refreshDriverCatalog(); this._checkTimer = setInterval(() => { this._refresh(false); this._refreshComponents(false); - this._refreshDriverCatalog(); }, CHECK_INTERVAL_MS); } @@ -225,7 +187,6 @@ this._refreshSnapshots(); // pull the list for the Snapshots accordion this._refreshBackups(); this._refreshComponentHistory(); - this._refreshDriverCatalog(); } // Fetch the snapshot list so the operator sees the retained set and @@ -365,82 +326,6 @@ .catch(() => { /* old backends do not expose history */ }); } - _refreshDriverCatalog() { - Promise.all([ - apiFetch("/api/drivers/catalog").then((r) => (r.ok ? r.json() : null)), - apiFetch("/api/config").then((r) => (r.ok ? r.json() : null)), - apiFetch("/api/device_repository/catalog?channel=beta").then((r) => (r.ok ? r.json() : { entries: [] })), - ]) - .then(([catalog, config, betaCatalog]) => { - if (!catalog || !config) return; - const configured = new Set((Array.isArray(config.drivers) ? config.drivers : []) - .map((driver) => driverFileKey(driver && driver.lua)) - .filter(Boolean)); - const betaByID = new Map((betaCatalog && Array.isArray(betaCatalog.entries) ? betaCatalog.entries : []) - .map((candidate) => [candidate && candidate.driver && candidate.driver.id, candidate])); - // Every configured driver is part of the inventory, whether or not - // it has an update waiting — the dialog answers "what am I running?" - // before it answers "what can I change?". Locally edited drivers are - // listed too, but carry no actions: nothing signed to move them to. - const entries = (Array.isArray(catalog.entries) ? catalog.entries : []) - .filter((entry) => configured.has(driverFileKey(entry && (entry.path || entry.filename)))) - .map((entry) => { - const beta = betaByID.get(entry.id) || null; - const current = entry.installed_version || entry.version || ""; - const betaDriver = beta && beta.driver; - const managed = entry.source !== "local"; - const stableAvailable = !!(managed && entry.update_available && entry.repository_id && entry.upstream_version); - const betaAvailable = !!(managed && betaDriver && betaDriver.version && isNewerVersion(betaDriver.version, current)); - return { - ...entry, - beta_candidate: beta, - managed, - stable_available: stableAvailable, - beta_available: betaAvailable, - pending_update: stableAvailable || betaAvailable, - }; - }); - this._driverCatalog = { entries }; - this._render(); - }) - .catch(() => { /* config or repository discovery may be unavailable */ }); - } - - _loadDriverVersions(id) { - if (!id) return; - apiFetch("/api/device_repository/drivers/" + encodeURIComponent(id) + "/versions") - .then(async (resp) => { - const body = await resp.json().catch(() => ({})); - if (!resp.ok) throw new Error(body.error || "failed to load driver history"); - this._driverVersions[id] = body; - this._render(); - }) - .catch((err) => window.alert("Driver history failed: " + err.message)); - } - - _changeDriverVersion(id, repositoryID, version, sha256, installed, channel) { - if (!id || !version || this._componentAction) return; - this._componentAction = "driver:" + id; - this._render(); - const url = "/api/device_repository/drivers/" + encodeURIComponent(id) + (installed ? "/activate" : "/install"); - const body = installed - ? { version, sha256 } - : { repository_id: repositoryID, version, ...(channel ? { channel } : {}) }; - this._postJSON(url, body) - .then((resp) => { - if (!resp.ok) throw new Error((resp.body && resp.body.error) || "driver update failed"); - delete this._driverVersions[id]; - this._refreshDriverCatalog(); - this._refreshComponents(false); - this._refreshComponentHistory(); - }) - .catch((err) => window.alert("Driver update failed: " + err.message)) - .finally(() => { - this._componentAction = ""; - this._render(); - }); - } - // _beginRollback kicks off a rollback-to-snapshot. Reuses the same // "updating" modal skin as _beginUpdate — the sidecar emits state // transitions (restoring → restarting → done) that feed straight @@ -733,26 +618,17 @@ // ---- render ---- - // _driverEntries is the full configured inventory; _pendingUpdates counts - // only what an operator could act on right now. The badge, the summary - // line and the footer all read the same count so they can't disagree. - _driverEntries() { - return this._driverCatalog && Array.isArray(this._driverCatalog.entries) - ? this._driverCatalog.entries - : []; - } - + // Driver versions are chosen per device under Settings › Devices, the + // one place they are shown and changed, so only Core counts here. _pendingUpdates() { const info = this._info || {}; const core = !!(info.update_available && !info.skipped); - // Native driver updates live in Settings › Devices, not in this panel. - const drivers = info.native ? 0 : this._driverEntries().filter((entry) => entry.pending_update).length; - return { core, drivers, total: (core ? 1 : 0) + drivers }; + return { core, total: core ? 1 : 0 }; } _render() { // A 503 from /api/version/check permanently disables this component. - // Component and driver requests start alongside that check, so their + // Component requests start alongside that check, so their // responses can arrive later. Never let one of those responses rebuild // controls that open() will refuse to use. if (this._disabled) { @@ -855,8 +731,6 @@ const hasUpdate = !!info.update_available; const pending = this._pendingUpdates(); - // The summary covers the whole inventory, not just Core: an operator - // with a waiting driver update should not read "up to date". const subtitle = pending.total === 0 ? (info.native && info.channel === "stable" && /-beta\./.test(info.current || "") ? "No newer 0.x stable package is ready yet." @@ -1041,7 +915,7 @@
${channelButtons}

${escapeHTML(channelNote)}

-

Drivers follow no channel. Each one is pinned to a version you pick per driver above, from either stream.

+

Drivers follow no channel. Each device runs the version chosen under Settings › Devices.

`; } @@ -1179,37 +1053,6 @@ ? `` : ""; - const entries = this._driverEntries(); - const driverRows = entries.map((entry) => { - const current = entry.installed_version || entry.version || "unknown"; - const busy = this._componentAction === "driver:" + entry.id; - const action = entry.stable_available - ? `` - : ""; - const beta = entry.beta_candidate || {}; - const betaDriver = beta.driver || {}; - const betaAction = entry.beta_available - ? `` - : ""; - const history = entry.repository_id - ? `` - : ""; - // A locally edited driver has no signed counterpart to move to, so it - // reports what it is instead of offering an action it cannot perform. - const target = entry.stable_available ? entry.upstream_version : (betaDriver.version || ""); - const status = !entry.managed - ? `local copy` - : entry.pending_update - ? `${escapeHTML(target)} available` - : `up to date`; - return ` - ${escapeHTML(entry.name || entry.id || "driver")} - ${escapeHTML(current)} - ${status} - ${action}${betaAction}${history} - ${this._driverVersionsHTML(entry.id)}`; - }).join(""); - const history = this._componentHistory && Array.isArray(this._componentHistory.events) ? this._componentHistory.events.slice(0, 8) : []; const historyRows = history.map((event) => { @@ -1245,29 +1088,16 @@ ${coreStatus} - ${driverRows} + + Drivers + Versions are chosen per device under Settings › Devices. + ${historyRows ? `
Update history${historyRows}
WhenComponentVersionResult
` : ""} `; } - _driverVersionsHTML(id) { - const payload = id && this._driverVersions[id]; - if (!payload) return ""; - const versions = Array.isArray(payload.available) ? payload.available : []; - if (!versions.length) return `No signed or retained versions found.`; - const rows = versions.map((candidate) => { - const driver = candidate.driver || {}; - const installed = candidate.installed || null; - const active = installed && installed.active; - const label = active ? "active" : (installed ? "Activate" : "Install"); - const button = active ? `active` : ``; - return `${escapeHTML(driver.version || "?")}${button}`; - }).join(""); - return `
${rows}
`; - } - _updatingModalHTML() { if (this._bootHealth) { return `