From 822d73f771e5faf8d5ec35d7f7c90297a50d3c67 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Fri, 25 Sep 2026 08:35:29 +0200 Subject: [PATCH 1/3] feat(drivers): one place to see and choose a driver version The owner's rule: an owner picks which signed driver version runs, it is seen and changed in one place, and nothing lights up on its own. - Each device's Versions list under Settings > Devices is that place. It lists the release's own copy first (with its version), the signed stable versions and any beta newer than stable, marked. The owner's choice is marked "chosen, kept across updates". Every version links to what changed: the driver's history in device-drivers at the commit it was published from. "Check for new versions" refreshes both signed channels and redraws the list; nothing installs. - The device line says where the running driver comes from: this release, chosen and kept across updates, until a release has it, or your own file. The "Update to vX" button is gone. - Update Center lists no drivers and the header counts only Core. System no longer refreshes driver catalogs. - Adding a device: "Look for more drivers" fetches the driver types the release does not carry, from the driver channel or, marked, from beta, into the same list. The add path installs them from their channel. The old list never offered stable channel-only drivers; they were reachable only through the beta selector. - API: /versions returns release_version, chosen_version, logical_path and the release's pinned source; candidates carry channel and repository. The catalog marks a chosen override. One refresh reads both channels. `ftw status` tells a chosen override from an early one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MuerPFZFG88kgu8sWVHeq7 --- .changeset/driver-versions-one-place.md | 13 + docs/device-repository.md | 5 + go/internal/api/api.go | 3 + go/internal/api/api_device_repository.go | 46 ++- go/internal/api/api_device_repository_test.go | 36 ++ go/internal/driverrepo/manager.go | 68 +++- go/internal/driverrepo/versions_test.go | 91 +++++ go/internal/drivers/catalog.go | 3 + go/internal/ftwcli/cli_test.go | 8 +- go/internal/ftwcli/status.go | 11 +- web/app.css | 6 + web/driver-source-suggest.test.mjs | 4 +- web/driver-update-list.test.mjs | 93 ++--- web/driver-versions.test.mjs | 185 ++++++--- web/header-status-marks.test.mjs | 7 +- web/settings/tabs/devices.js | 379 +++++++++++------- web/settings/tabs/system.js | 19 +- web/update-badge.js | 204 +--------- web/update-channel-wiring.test.mjs | 15 +- web/update-dialog.test.mjs | 5 +- 20 files changed, 690 insertions(+), 511 deletions(-) create mode 100644 .changeset/driver-versions-one-place.md create mode 100644 go/internal/driverrepo/versions_test.go 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..d309f1f05 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" @@ -278,7 +279,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 +435,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..9a5c1b86f 100644 --- a/go/internal/driverrepo/manager.go +++ b/go/internal/driverrepo/manager.go @@ -107,9 +107,15 @@ 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 { @@ -300,6 +306,12 @@ func (m *Manager) Refresh(ctx context.Context, repositoryID string) error { errs = append(errs, fmt.Errorf("%s: %w", repo.ID, err)) } } + // One refresh checks every version an owner can pick, beta included. + if repositoryID == "" { + if err := m.refreshOne(ctx, m.betaRepo); err != nil { + errs = append(errs, fmt.Errorf("%s: %w", m.betaRepo.ID, err)) + } + } if repositoryID != "" && len(errs) == 0 { found := false for _, repo := range m.cfg.Repositories { @@ -741,24 +753,46 @@ func (m *Manager) AvailableVersions(driverID string) ([]VersionCandidate, error) } 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} + offered[strings.ToLower(driver.SHA256)] = true + candidate := VersionCandidate{RepositoryID: repo.ID, Channel: channel, Repository: manifest.Repository, Driver: driver} if artifact, ok := installedByKey[key]; ok { copy := artifact candidate.Installed = © @@ -777,8 +811,13 @@ func (m *Manager) AvailableVersions(driverID string) ([]VersionCandidate, error) } 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 +967,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..4d022a225 --- /dev/null +++ b/go/internal/driverrepo/versions_test.go @@ -0,0 +1,91 @@ +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 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}, + } + if err := manager.Refresh(context.Background(), ""); err != nil { + t.Fatal(err) + } + 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") + if err := manager.Refresh(context.Background(), ""); err != nil { + t.Fatal(err) + } + 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") + if err := manager.Refresh(context.Background(), ""); err != nil { + t.Fatal(err) + } + 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) + } +} 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..44ee01d7e 100644 --- a/web/driver-update-list.test.mjs +++ b/web/driver-update-list.test.mjs @@ -1,45 +1,48 @@ +// 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"\)/); + 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 +56,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 +63,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..30a357d87 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,81 @@ 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"); +}); 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..0da8ba555 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -152,10 +152,16 @@ // 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 (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 +173,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 +235,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 +254,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 +341,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 +361,65 @@ 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"; + 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 () { + 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); + }).catch(function (err) { + status.textContent = err.message; + check.disabled = false; + }); + }); + line.appendChild(check); + line.appendChild(status); + panel.appendChild(line); } function configReloadNote(body) { @@ -359,6 +444,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 +460,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 +486,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 +540,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 +553,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); } @@ -543,12 +638,6 @@ 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 +648,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 +682,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 +774,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 +924,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 +990,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 +1006,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 +1060,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 +1070,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 +1358,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 +1472,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 +1533,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 +1598,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 +1614,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 +1799,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 +1809,61 @@ 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…"; + Promise.all([ + fetchCatalog("/api/device_repository/catalog"), + fetchCatalog("/api/device_repository/catalog?channel=beta") + ]).then(function (results) { + 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"); + if (status) status.textContent = added.length === 0 + ? "The release already has every signed driver." + : "Added " + parts.join(" and ") + "."; + }).catch(function (err) { + if (status) status.textContent = err.message; + moreDrivers.disabled = false; + }); }); var catalogPicker = document.getElementById("driver-catalog-picker"); @@ -1871,19 +1955,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 +1979,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 `