diff --git a/.changeset/driver-versions-and-override-visibility.md b/.changeset/driver-versions-and-override-visibility.md new file mode 100644 index 00000000..91e917ea --- /dev/null +++ b/.changeset/driver-versions-and-override-visibility.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +Pick which driver version runs, and see when your own copy has fallen behind. The signed channel keeps every version it has ever published, but rollback only stepped back one, so a specific older version was out of reach from the UI. Each managed driver now has a Versions list showing what is downloaded, what the channel offers, and which one is live. A driver you supply yourself is now also told when the channel has something newer — your copy keeps running until you decide otherwise. diff --git a/go/internal/driverrepo/manager.go b/go/internal/driverrepo/manager.go index a7a71119..1d08a569 100644 --- a/go/internal/driverrepo/manager.go +++ b/go/internal/driverrepo/manager.go @@ -414,9 +414,10 @@ func (m *Manager) EnrichCatalog(entries []drivers.CatalogEntry) []drivers.Catalo activeByLogical[in.LogicalPath] = in } for i := range entries { - if entries[i].Source == "local" { - continue - } + // A local file is an operator's own copy and is not a managed install, + // so none of the install metadata below applies to it. It still needs + // the upstream comparison: an override shadows the channel silently, + // and without this the operator is never told a newer version exists. if in, ok := activeByLogical[entries[i].Path]; ok && entries[i].Source == "managed" { entries[i].InstalledVersion = in.Version entries[i].RepositoryID = in.RepoID @@ -434,20 +435,31 @@ func (m *Manager) EnrichCatalog(entries []drivers.CatalogEntry) []drivers.Catalo break } } + local := entries[i].Source == "local" for _, candidate := range candidates { if candidate.Driver.ID != entries[i].ID { continue } if entries[i].UpstreamVersion == "" || compareSemver(candidate.Driver.Version, entries[i].UpstreamVersion) > 0 { entries[i].UpstreamVersion = candidate.Driver.Version - entries[i].RepositoryID = candidate.RepositoryID + // A local file came from the operator, not from a repository. + // Naming one here would read as provenance it does not have. + if !local { + entries[i].RepositoryID = candidate.RepositoryID + } } } base := entries[i].Version if entries[i].InstalledVersion != "" { base = entries[i].InstalledVersion } - entries[i].UpdateAvailable = entries[i].UpstreamVersion != "" && compareSemver(entries[i].UpstreamVersion, base) > 0 + // Only claim an update when both sides are real versions that can be + // ordered. A local driver often carries no version, or something like + // "local", and comparing that as a string would announce an update + // on nothing better than alphabetical luck. + entries[i].UpdateAvailable = entries[i].UpstreamVersion != "" && + isSemver(entries[i].UpstreamVersion) && isSemver(base) && + compareSemver(entries[i].UpstreamVersion, base) > 0 } return entries } @@ -1073,6 +1085,13 @@ func decodeBase64(value string) ([]byte, error) { var semverRE = regexp.MustCompile(`^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`) +// isSemver reports whether a version can be ordered at all. compareSemver +// falls back to comparing strings, which is fine for sorting and wrong for +// deciding whether a newer release exists. +func isSemver(value string) bool { + return semverRE.MatchString(value) +} + func compareSemver(a, b string) int { type version struct { core [3]int diff --git a/go/internal/driverrepo/sourceful_test.go b/go/internal/driverrepo/sourceful_test.go index e90a197c..7d8b119f 100644 --- a/go/internal/driverrepo/sourceful_test.go +++ b/go/internal/driverrepo/sourceful_test.go @@ -285,6 +285,39 @@ func TestSourcefulIndexPackageInstallAndOfflineCache(t *testing.T) { if local.RepositoryID != "" || local.PackageID != "" || local.ArtifactSHA256 != "" || local.UpdateAvailable { t.Fatalf("local catalog claimed managed provenance = %+v", local) } + // An override still has to be told what the channel offers. Running your + // own copy shadows the channel, so without this the operator is never told + // a newer version exists -- and the version they keep is their choice to + // make, not something to be silently replaced. + if local.UpstreamVersion != "1.1.1" { + t.Fatalf("local override was not told the channel has 1.1.1: %+v", local) + } + + // An override carrying a real, older version is a genuine update, and the + // operator should see it. Resolution is unchanged: the local file keeps + // winning until they act on it. + older := manager.EnrichCatalog([]drivers.CatalogEntry{{ + Path: "drivers/sdm630.lua", ID: "sdm630", Version: "1.0.0", Source: "local", + }})[0] + if !older.UpdateAvailable || older.UpstreamVersion != "1.1.1" { + t.Fatalf("older local override should offer an update = %+v", older) + } + if older.RepositoryID != "" || older.PackageID != "" { + t.Fatalf("an update offer is not provenance = %+v", older) + } + + // A version that cannot be ordered must not produce a claim either way. + // compareSemver falls back to comparing strings, which would announce an + // update on alphabetical luck. + unversioned := manager.EnrichCatalog([]drivers.CatalogEntry{{ + Path: "drivers/sdm630.lua", ID: "sdm630", Source: "local", + }})[0] + if unversioned.UpdateAvailable { + t.Fatalf("an unversioned override cannot be compared = %+v", unversioned) + } + if unversioned.UpstreamVersion != "1.1.1" { + t.Fatalf("it should still be told what exists = %+v", unversioned) + } policy, err := manager.RuntimePolicy(config.Driver{ Name: "sdm630", Lua: filepath.Join(manager.ActiveDir(), "sdm630.lua"), }) diff --git a/web/driver-versions.test.mjs b/web/driver-versions.test.mjs new file mode 100644 index 00000000..dad52158 --- /dev/null +++ b/web/driver-versions.test.mjs @@ -0,0 +1,91 @@ +// Choosing which driver version runs, and never being surprised by it. +// +// Two properties matter here and they pull in opposite directions: +// +// 1. An operator running their own copy must still be told when the channel +// has something newer. An override shadows the channel silently, so +// without this they never find out. +// +// 2. Being told must not mean being changed. Their copy keeps running until +// they act. The channel keeps every version it has ever signed, and +// reaching a specific one is their decision. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(join(here, "settings/tabs/devices.js"), "utf8"); + +// Just renderVersionPicker, so assertions about it cannot be satisfied or +// broken by unrelated code further down the file. +function renderVersionPickerBody() { + const start = source.indexOf("function renderVersionPicker"); + assert.notEqual(start, -1, "renderVersionPicker must exist"); + const next = source.indexOf("\n function ", start + 1); + return source.slice(start, next === -1 ? undefined : next); +} + +test("a local override is told what the channel has", () => { + assert.match(source, /source === "local" && entry\.upstream_version/, + "an override shadows the channel, so it must surface the newer version " + + "rather than leaving the operator to find out some other way"); + assert.match(source, /channel has v/, + "the version itself has to be on screen, not just the fact of an update"); +}); + +test("the override is not offered a button that would do nothing", () => { + // Installing a channel version while a local file is present changes + // nothing: the local file still wins. A button implying otherwise would be + // a lie the operator only discovers by debugging. + const localBranch = source.slice( + source.indexOf('source === "local" && entry.upstream_version'), + source.indexOf('if (source === "managed")')); + assert.ok(!/drv-module-update/.test(localBranch), + "the update button belongs to managed drivers, not overrides"); + assert.match(localBranch, /your own copy is used while it is present/, + "say why nothing is being offered"); +}); + +test("the version picker is offered for managed drivers only", () => { + assert.match(source, /source !== "local"[\s\S]{0,200}drv-module-versions/, + "an override has no channel versions to switch between"); +}); + +test("the picker distinguishes downloaded versions from ones still in the channel", () => { + const render = renderVersionPickerBody(); + assert.match(render, /body && body\.installed/); + assert.match(render, /body && body\.available/); + + // Activating something already on disk needs no network; fetching does. + // The labels have to tell those apart or the operator cannot tell why one + // is instant and the other is not. + assert.match(render, /row\.local \? "Activate" : "Fetch and activate"/); + assert.match(render, /row\.local[\s\S]{0,120}"\/activate"[\s\S]{0,40}"\/install"/, + "an installed version activates; one that is not installed installs"); +}); + +test("the running version is marked and cannot be re-activated", () => { + const render = renderVersionPickerBody(); + assert.match(render, /row\.active \? " · running" : ""/, + "an operator must be able to see which one is live"); + assert.match(render, /if \(!row\.active\) \{/, + "no button on the version that is already running"); +}); + +test("the picker builds DOM instead of assembling HTML", () => { + const render = renderVersionPickerBody(); + assert.ok(!/innerHTML/.test(render), + "version strings come from a signed manifest, but the manifest is still " + + "remote input and must not be able to inject markup"); + assert.match(render, /createElement\("button"\)/); + assert.match(render, /textContent = "v" \+ row\.version/); +}); + +test("an empty history says so rather than rendering nothing", () => { + const render = renderVersionPickerBody(); + assert.match(render, /No versions found for this driver/, + "a silently empty panel looks like a broken request"); +}); diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index 79d1ff22..433611d0 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -42,6 +42,90 @@ return true; } + // Every version the channel has signed for this driver, and which one is + // running. Rollback steps back exactly one; this is how an operator reaches + // a specific older version, which is the whole reason the channel keeps a + // history in the first place. + function renderVersionPicker(panel, driverID, body) { + var installed = (body && body.installed) || []; + var available = (body && body.available) || []; + panel.textContent = ""; + + if (installed.length === 0 && available.length === 0) { + panel.textContent = "No versions found for this driver."; + return; + } + + // Downloaded already: activating one is instant and needs no network. + var installedVersions = {}; + installed.forEach(function (v) { if (v && v.version) installedVersions[v.version] = true; }); + + var rows = []; + installed.forEach(function (v) { + rows.push({version: v.version, sha256: v.sha256, active: !!v.active, local: true}); + }); + available.forEach(function (v) { + var version = typeof v === "string" ? v : (v && v.version); + if (!version || installedVersions[version]) return; + rows.push({version: version, active: false, local: false}); + }); + + rows.forEach(function (row) { + var line = document.createElement("div"); + line.style.display = "flex"; + line.style.alignItems = "center"; + line.style.gap = "8px"; + line.style.marginTop = "4px"; + + var label = document.createElement("span"); + label.className = "creds-badge"; + label.textContent = "v" + row.version + (row.active ? " · running" : ""); + line.appendChild(label); + + if (!row.active) { + var action = document.createElement("button"); + action.type = "button"; + action.className = "btn-add"; + // Already downloaded, so this only switches which one runs. A version + // that is not downloaded has to be fetched from the channel first. + action.textContent = row.local ? "Activate" : "Fetch and activate"; + action.addEventListener("click", function () { + action.disabled = true; + var status = line.querySelector(".drv-version-status"); + var endpoint = row.local ? "/activate" : "/install"; + var payload = row.local + ? {version: row.version, sha256: row.sha256 || ""} + : {version: row.version}; + if (status) status.textContent = row.local ? "Activating…" : "Fetching…"; + apiFetch("/api/device_repository/drivers/" + encodeURIComponent(driverID) + endpoint, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(payload) + }).then(function (r) { + return r.json().then(function (b) { + if (!r.ok) throw new Error(b.error || "could not switch version"); + return b; + }); + }).then(function () { + if (status) status.textContent = "v" + row.version + " is now running."; + }).catch(function (err) { + if (status) status.textContent = err.message; + action.disabled = false; + }); + }); + line.appendChild(action); + } + + var status = document.createElement("span"); + status.className = "drv-version-status"; + status.style.color = "var(--text-dim)"; + status.style.fontSize = "0.75rem"; + line.appendChild(status); + + panel.appendChild(line); + }); + } + function configProfilesFor(entry) { if (!entry || entry.id !== "goodwe" || !versionAtLeast(entry.version, "1.0.2")) return []; return DRIVER_CONFIG_PROFILES.goodwe; @@ -448,11 +532,27 @@ '" data-repository-id="' + escHtml(entry.repository_id) + '" data-version="' + escHtml(entry.upstream_version) + '">Update to v' + escHtml(entry.upstream_version) + ''; } + // An override shadows the channel, so installing a newer version + // would not change what runs. Say that, rather than offering a + // button that appears to do nothing. Their copy stays active until + // they remove it themselves. + if (source === "local" && entry.upstream_version) { + html += ' channel has v' + escHtml(entry.upstream_version) + ''; + html += ' ' + + 'your own copy is used while it is present'; + } if (source === "managed") { html += ' '; } + // Rollback only steps back one version. The channel keeps every + // version it has ever signed, so let an operator pick from them. + if (source !== "local") { + html += ' '; + } html += ' '; + html += ''; slot.innerHTML = html; }); bodyEl.querySelectorAll(".drv-module-update").forEach(function (btn) { @@ -481,6 +581,25 @@ .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"); + if (!panel) return; + if (panel.style.display !== "none") { panel.style.display = "none"; return; } + panel.style.display = "block"; + panel.textContent = "Loading versions…"; + var id = btn.dataset.driverId; + apiFetch("/api/device_repository/drivers/" + encodeURIComponent(id) + "/versions") + .then(function (r) { + return r.json().then(function (body) { + if (!r.ok) throw new Error(body.error || "could not list versions"); + return body; + }); + }) + .then(function (body) { renderVersionPicker(panel, id, body); }) + .catch(function (err) { panel.textContent = err.message; }); + }); + }); bodyEl.querySelectorAll(".drv-profile-slot").forEach(function (slot) { var dIdx = parseInt(slot.getAttribute("data-driver-idx"), 10); var d = config.drivers[dIdx];