From 3bee588c9a0cee415d705bf83bc81f1eeb91aa34 Mon Sep 17 00:00:00 2001 From: makermelissa-piclaw Date: Wed, 6 May 2026 11:48:27 -0700 Subject: [PATCH 1/3] Suggest firmware updates in device info dialogs (refs #357) Adds js/common/firmware-check.js, a small helper that: - Parses CircuitPython version strings (stable + alpha/beta/rc pre-releases, including '-dirty' build suffixes) into a comparable structure. - Fetches the latest stable and the latest dev pre-release of CircuitPython from the adafruit/circuitpython GitHub releases API (cached per page load). - Computes which updates are worth surfacing for the device's current version, following the rules from #357: * If the user is on a development release, suggest a newer stable and/or a newer dev release when available. * If the user is on a stable release, suggest a newer stable and/or any newer dev release. Both DiscoveryModal and DeviceInfoModal now render those suggestions under the device info table with a link to the board's circuitpython.org download page (which only lists firmware that is actually built for that board). The container collapses when there is nothing to show, and any failure of the GitHub API call is non-fatal and silently leaves the dialog unchanged. --- index.html | 2 + js/common/dialogs.js | 9 ++ js/common/firmware-check.js | 200 ++++++++++++++++++++++++++++++++++++ sass/layout/_layout.scss | 37 +++++++ 4 files changed, 248 insertions(+) create mode 100644 js/common/firmware-check.js diff --git a/index.html b/index.html index e937f604..b5b12b73 100644 --- a/index.html +++ b/index.html @@ -391,6 +391,7 @@

Select USB Host Folder

+

More network devices

@@ -432,6 +433,7 @@

More network devices

diff --git a/js/common/dialogs.js b/js/common/dialogs.js index 4c0295ca..42e2f7d8 100644 --- a/js/common/dialogs.js +++ b/js/common/dialogs.js @@ -1,4 +1,5 @@ import {sleep, isIp, switchDevice} from './utilities.js'; +import {renderFirmwareSuggestions} from './firmware-check.js'; import * as focusTrap from 'focus-trap'; const SELECTOR_CLOSE_BUTTON = ".popup-modal__close"; @@ -357,6 +358,10 @@ class DiscoveryModal extends GenericModal { this._currentModal.querySelector("#mcuname").textContent = deviceInfo.mcu_name; this._currentModal.querySelector("#boardid").textContent = deviceInfo.board_id; this._currentModal.querySelector("#uid").textContent = deviceInfo.uid; + const updateContainer = this._currentModal.querySelector("#firmware-update"); + if (updateContainer) { + renderFirmwareSuggestions(updateContainer, deviceInfo); + } } async _refreshDevices() { @@ -417,6 +422,10 @@ class DeviceInfoModal extends GenericModal { this._currentModal.querySelector("#mcuname").textContent = deviceInfo.mcu_name; this._currentModal.querySelector("#boardid").textContent = deviceInfo.board_id; this._currentModal.querySelector("#uid").textContent = deviceInfo.uid; + const updateContainer = this._currentModal.querySelector("#firmware-update"); + if (updateContainer) { + renderFirmwareSuggestions(updateContainer, deviceInfo); + } } async open(workflow, documentState) { diff --git a/js/common/firmware-check.js b/js/common/firmware-check.js new file mode 100644 index 00000000..8b56f409 --- /dev/null +++ b/js/common/firmware-check.js @@ -0,0 +1,200 @@ +// Helpers for comparing CircuitPython firmware versions and surfacing +// "newer firmware available" suggestions in the UI. +// +// CircuitPython release tags follow a SemVer-ish form, e.g.: +// 9.2.8 stable +// 10.0.0-alpha.1 development pre-release +// 10.0.0-beta.0 +// 10.0.0-rc.0 +// +// We parse the version into a tuple we can compare numerically and use the +// GitHub releases API for adafruit/circuitpython to find the latest stable +// and the latest dev (prerelease) versions. Per-board availability is left +// to the linked board page on circuitpython.org, which only lists builds +// that actually exist for that board. +// +// See https://github.com/circuitpython/web-editor/issues/357 + +const RELEASES_API = "https://api.github.com/repos/adafruit/circuitpython/releases"; + +// Cache the API result for the lifetime of the page so opening the device +// info dialog repeatedly doesn't hammer the API. +let _releasesPromise = null; + +// Pre-release identifier ranking. Lower number = earlier in the release cycle. +// Anything not listed (or an empty pre-release section) is treated as a final +// stable release and ranks highest within the same X.Y.Z. +const PRERELEASE_RANK = { + "alpha": 0, + "beta": 1, + "rc": 2, +}; + +// Parse a version string like "9.2.8", "10.0.0-alpha.1", or "10.0.0-rc.0" +// into a comparable structure. Returns null if it can't be parsed. +function parseVersion(versionString) { + if (typeof versionString !== "string") return null; + // Trim a leading "v" and any trailing build metadata after "+" + let raw = versionString.trim().replace(/^v/i, "").split("+", 1)[0]; + // Tolerate "-dirty" suffix on builds compiled from a working tree + raw = raw.replace(/-dirty$/i, ""); + const match = raw.match(/^(\d+)\.(\d+)\.(\d+)(?:[-.]([A-Za-z]+)\.?(\d+)?)?$/); + if (!match) return null; + + const [, maj, min, patch, preLabel, preNum] = match; + let preRank = Number.POSITIVE_INFINITY; // stable releases rank above any pre-release + let preNumber = 0; + let isPrerelease = false; + if (preLabel) { + isPrerelease = true; + const label = preLabel.toLowerCase(); + preRank = label in PRERELEASE_RANK ? PRERELEASE_RANK[label] : -1; + preNumber = preNum ? parseInt(preNum, 10) : 0; + } + return { + raw: versionString, + major: parseInt(maj, 10), + minor: parseInt(min, 10), + patch: parseInt(patch, 10), + prerelease: isPrerelease, + preRank, + preNumber, + }; +} + +// Compare two parsed versions. Returns negative if a < b, positive if a > b, 0 if equal. +function compareVersions(a, b) { + if (!a && !b) return 0; + if (!a) return -1; + if (!b) return 1; + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + if (a.patch !== b.patch) return a.patch - b.patch; + if (a.preRank !== b.preRank) return a.preRank - b.preRank; + return a.preNumber - b.preNumber; +} + +// Fetch (and cache) the list of CircuitPython releases from GitHub and pick +// the highest stable + highest dev pre-release. Returns +// { stable: parsedVersion|null, dev: parsedVersion|null }. +async function fetchLatestReleases() { + if (_releasesPromise) return _releasesPromise; + + _releasesPromise = (async () => { + let response; + try { + response = await fetch(`${RELEASES_API}?per_page=30`, { + headers: {"Accept": "application/vnd.github+json"}, + }); + } catch (err) { + console.warn("Firmware check: fetch failed", err); + return {stable: null, dev: null}; + } + if (!response.ok) { + console.warn("Firmware check: GitHub API returned", response.status); + return {stable: null, dev: null}; + } + let releases; + try { + releases = await response.json(); + } catch (err) { + console.warn("Firmware check: bad JSON from GitHub", err); + return {stable: null, dev: null}; + } + + let stable = null; + let dev = null; + for (const release of releases) { + if (release.draft) continue; + const parsed = parseVersion(release.tag_name); + if (!parsed) continue; + if (release.prerelease || parsed.prerelease) { + if (compareVersions(parsed, dev) > 0) dev = parsed; + } else { + if (compareVersions(parsed, stable) > 0) stable = parsed; + } + } + return {stable, dev}; + })(); + + return _releasesPromise; +} + +// Decide which (if any) firmware suggestions to surface for a device that +// is currently running `currentVersionString`. Implements the logic from +// https://github.com/circuitpython/web-editor/issues/357: +// +// - If the user is running a development release: +// - Suggest the latest stable if it is newer. +// - Suggest the latest dev release if it is newer than what they're running. +// - If the user is running a stable release: +// - Suggest a newer stable, if any. +// - Suggest a newer dev release, if any. +// +// Returns { suggestions: [{type: "stable"|"dev", version: "10.0.0"}], current }. +function buildSuggestions(currentVersionString, latestReleases) { + const current = parseVersion(currentVersionString); + const suggestions = []; + if (!current || !latestReleases) { + return {suggestions, current}; + } + const {stable, dev} = latestReleases; + + if (stable && compareVersions(stable, current) > 0) { + suggestions.push({type: "stable", version: stable.raw}); + } + if (dev && compareVersions(dev, current) > 0) { + suggestions.push({type: "dev", version: dev.raw}); + } + return {suggestions, current}; +} + +// Format suggestions as a small HTML snippet suitable for injecting into a +// device info table. `boardId` is used to deep-link to the board's download +// page on circuitpython.org. Returns an empty string when there is nothing +// to suggest. +function renderSuggestionsHtml(suggestions, boardId) { + if (!suggestions || suggestions.length === 0) return ""; + const safeBoard = encodeURIComponent(boardId || ""); + const link = safeBoard + ? `https://circuitpython.org/board/${safeBoard}/` + : "https://circuitpython.org/downloads"; + const items = suggestions.map((s) => { + const label = s.type === "dev" ? "development release" : "stable release"; + return `
  • Newer ${label} available: ${s.version}
  • `; + }).join(""); + return ( + `
    ` + + ` ` + + `Update available` + + `
      ${items}
    ` + + `Download from circuitpython.org` + + `
    ` + ); +} + +// Convenience: fetch latest releases, compute suggestions for the given +// device version + board, and (if any) render them into `containerElement`. +// Failures are non-fatal -- nothing is rendered if the API call fails or the +// version string can't be parsed. +async function renderFirmwareSuggestions(containerElement, deviceInfo) { + if (!containerElement || !deviceInfo) return; + try { + const latest = await fetchLatestReleases(); + const {suggestions} = buildSuggestions(deviceInfo.version, latest); + const html = renderSuggestionsHtml(suggestions, deviceInfo.board_id); + containerElement.innerHTML = html; + } catch (err) { + console.warn("Firmware check failed", err); + containerElement.innerHTML = ""; + } +} + +export { + parseVersion, + compareVersions, + fetchLatestReleases, + buildSuggestions, + renderSuggestionsHtml, + renderFirmwareSuggestions, +}; diff --git a/sass/layout/_layout.scss b/sass/layout/_layout.scss index b9f808ec..cd4f108f 100644 --- a/sass/layout/_layout.scss +++ b/sass/layout/_layout.scss @@ -434,6 +434,43 @@ &[data-popup-modal="device-discovery"], &[data-popup-modal="device-info"] { + .firmware-update-suggestion-container { + // Filled in by firmware-check.js when a newer firmware is found. + // Empty by default so the dialog layout doesn't shift when the + // GitHub releases API hasn't responded yet (or fails). + &:empty { + display: none; + } + } + + .firmware-update-suggestion { + margin: 10px 0 5px; + padding: 10px 12px; + border: 1px solid $light-purple; + border-radius: 5px; + background-color: #faf6ff; + font-size: 0.95rem; + + i { + color: $purple; + margin-right: 6px; + } + + .firmware-update-suggestion__title { + font-weight: bold; + } + + ul { + margin: 6px 0; + padding-left: 24px; + } + + a { + color: $purple; + font-weight: bold; + } + } + .device-info { margin-top: 5px; width: 100%; From 67012d6c401d3c21f5685269c6b3e92dee378f2b Mon Sep 17 00:00:00 2001 From: makermelissa-piclaw Date: Wed, 6 May 2026 12:09:07 -0700 Subject: [PATCH 2/3] Auto-open Device Info dialog after every fresh connect Previously, only the Web workflow opened the Device Info / Discovery dialog automatically after connecting. USB and BLE users had to click the Info button to see the firmware-update suggestion added in this PR. This change moves the post-connect 'show info' trigger into loadEditor() so all three workflows (Web / USB / BLE) behave the same way: connect once, see the dialog, see the firmware-update suggestion if any. To avoid spamming the dialog on silent reconnects, a one-shot flag (shownDeviceInfoForCurrentSession) tracks whether we've already shown it for the current connection; the flag is reset in disconnectCallback so a fresh connect always re-shows the dialog. The dialog is opened fire-and-forget so it doesn't block the rest of the post-connect flow. Removes the now-redundant explicit showInfo() calls in checkConnected() and the URL-backend bootstrap path. --- js/script.js | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/js/script.js b/js/script.js index 7a2d501e..19f33e0c 100644 --- a/js/script.js +++ b/js/script.js @@ -235,10 +235,9 @@ async function checkConnected() { if (!workflow.connectionStatus()) { // Display the appropriate connection dialog await workflow.showConnect(getDocState()); - } else if (workflow.type === CONNTYPE.Web) { - // We're connected, local, and using Web Workflow - await workflow.showInfo(getDocState()); } + // Note: the Device Info dialog is now opened from loadEditor() so that + // BLE/USB/Web all behave the same way after a fresh connect. } return true; @@ -466,6 +465,12 @@ window.onbeforeunload = () => { } }; +// Tracks whether we've already shown the post-connect Device Info dialog +// for the current workflow. Reset to false in disconnectCallback() so that +// a fresh connect always re-shows it, while silent reconnects (which also +// run loadEditor) do not. +let shownDeviceInfoForCurrentSession = false; + async function loadEditor() { let documentState = loadParameterizedContent(); if (documentState) { @@ -475,6 +480,24 @@ async function loadEditor() { } updateUIConnected(true); + + // Show the Device Info dialog once per fresh connect, regardless of + // workflow (Web / USB / BLE). This is where the firmware-update + // suggestion (issue #357) is surfaced, so the user notices it just + // after connecting without us introducing a new dialog. + // + // Fire-and-forget: we don't await the dialog because it stays open until + // the user dismisses it, and we don't want to block the rest of the + // post-connect flow (busy spinner, parameterized doc loading, etc.). + if (!shownDeviceInfoForCurrentSession + && workflow + && workflow.showInfo + && workflow.connectionStatus && workflow.connectionStatus()) { + shownDeviceInfoForCurrentSession = true; + Promise.resolve() + .then(() => workflow.showInfo(getDocState())) + .catch((err) => console.warn("Could not show device info dialog", err)); + } } var editor; @@ -567,6 +590,7 @@ function disconnectCallback() { currentTimeout = null; } saveRetryCount = 0; + shownDeviceInfoForCurrentSession = false; updateUIConnected(false); } @@ -682,10 +706,10 @@ document.addEventListener('DOMContentLoaded', async (event) => { // If we don't have all the info we need to connect let returnVal = await workflow.parseParams(); if (returnVal === true && await workflowConnect() && workflow.type === CONNTYPE.Web) { - if (await checkReadOnly()) { - // We're connected, local, no errors, and using Web Workflow - await workflow.showInfo(getDocState()); - } + // We're connected, local, no errors, and using Web Workflow. + // The Device Info dialog is opened from loadEditor() now, so we + // just need to verify read-only state here. + await checkReadOnly(); } else { if (returnVal instanceof Error) { await showMessage(returnVal); From ffcc069429efe23f61105ee4071f5b2b2ae5aa9a Mon Sep 17 00:00:00 2001 From: makermelissa-piclaw Date: Wed, 6 May 2026 12:15:15 -0700 Subject: [PATCH 3/3] Fix USB: flip connected state before calling loadEditor In USBWorkflow.onConnected, loadEditor() was being called BEFORE super.onConnected() set _connected = CONNSTATE.connected. The new post-connect Device Info dialog trigger gates on connectionStatus(), which requires _connected == connected, so the dialog never opened for USB users. Reorder so super.onConnected() (which both flips the state flag and closes the connect dialog) runs first, then loadEditor() runs with connectionStatus() true. Also drop the redundant explicit connectDialog.close() call -- super.onConnected already closes it. --- js/workflows/usb.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/js/workflows/usb.js b/js/workflows/usb.js index 26532cfc..6298ec9b 100644 --- a/js/workflows/usb.js +++ b/js/workflows/usb.js @@ -45,10 +45,13 @@ class USBWorkflow extends Workflow { } async onConnected(e) { - this.connectDialog.close(); - await this.loadEditor(); + // super.onConnected sets _connected=CONNSTATE.connected and closes + // the connect dialog. Run it first so that loadEditor() (and any + // other code that gates on connectionStatus()) sees us as fully + // connected. + await super.onConnected(e); this.debugLog("connected"); - super.onConnected(e); + await this.loadEditor(); } async onDisconnected(e, reconnect = true) {