diff --git a/index.html b/index.html index e937f60..b5b12b7 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 4c0295c..42e2f7d 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 0000000..8b56f40 --- /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` + + `` + + `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/js/script.js b/js/script.js index 7a2d501..19f33e0 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); diff --git a/js/workflows/usb.js b/js/workflows/usb.js index 26532cf..6298ec9 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) { diff --git a/sass/layout/_layout.scss b/sass/layout/_layout.scss index b9f808e..cd4f108 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%;