diff --git a/.github/workflows/custom_build.yml b/.github/workflows/custom_build.yml index 64cb284..f053350 100644 --- a/.github/workflows/custom_build.yml +++ b/.github/workflows/custom_build.yml @@ -127,12 +127,14 @@ jobs: mkdir -p "$STAGE" shopt -s nullglob for f in "$BUILD_DIR"/*.bin "$BUILD_DIR"/*.uf2 "$BUILD_DIR"/*.hex; do + case "$f" in *.factory.bin) continue ;; esac cp -a "$f" "$STAGE/" done shopt -u nullglob if [ -f "$BUILD_DIR/flash-manifest.json" ]; then cp -a "$BUILD_DIR/flash-manifest.json" "$STAGE/" fi + bash "${{ github.workspace }}/scripts/stage-fw-bundle-docs.sh" "$ROOT" "$STAGE" if [ -z "$(find "$STAGE" -mindepth 1 -maxdepth 1 -type f -print -quit)" ]; then echo "No firmware artifacts found in $BUILD_DIR" exit 1 diff --git a/.github/workflows/custom_build_test.yml b/.github/workflows/custom_build_test.yml index 64cb284..f053350 100644 --- a/.github/workflows/custom_build_test.yml +++ b/.github/workflows/custom_build_test.yml @@ -127,12 +127,14 @@ jobs: mkdir -p "$STAGE" shopt -s nullglob for f in "$BUILD_DIR"/*.bin "$BUILD_DIR"/*.uf2 "$BUILD_DIR"/*.hex; do + case "$f" in *.factory.bin) continue ;; esac cp -a "$f" "$STAGE/" done shopt -u nullglob if [ -f "$BUILD_DIR/flash-manifest.json" ]; then cp -a "$BUILD_DIR/flash-manifest.json" "$STAGE/" fi + bash "${{ github.workspace }}/scripts/stage-fw-bundle-docs.sh" "$ROOT" "$STAGE" if [ -z "$(find "$STAGE" -mindepth 1 -maxdepth 1 -type f -print -quit)" ]; then echo "No firmware artifacts found in $BUILD_DIR" exit 1 diff --git a/scripts/emit-flash-manifest.py b/scripts/emit-flash-manifest.py index 55a036c..15c9ab9 100644 --- a/scripts/emit-flash-manifest.py +++ b/scripts/emit-flash-manifest.py @@ -6,7 +6,9 @@ If BUILD_DIR/flash-manifest.json already exists (project-supplied), merge in targetFamily from PlatformIO when missing. Otherwise, if Meshtastic-style *.mt.json is present, synthesize a manifest -from partition table + on-disk artifacts. +from partition table + on-disk split artifacts (no *.factory.bin — USB bundles +omit the merged image). Only LittleFS rows use optional:true (Mesh Forge: +Reset device storage). Optional args: PROJECT_ROOT TARGET_ENV — merged PIO config for that env fills targetFamily (and platform/board) for the USB flasher UI. @@ -148,20 +150,6 @@ def part_offset_for_slot(parts: list[dict], part_name: str) -> int | None: return None -def factory_app_offset(parts: list[dict]) -> int: - for p in parts: - if str(p.get("subtype", "")) == "factory": - o = parse_offset(p.get("offset")) - if o is not None: - return o - for p in parts: - if p.get("type") == "app" and p.get("subtype") == "ota_0": - o = parse_offset(p.get("offset")) - if o is not None: - return o - return 0x10000 - - def ota1_offset(parts: list[dict]) -> int | None: for p in parts: if str(p.get("subtype", "")) == "ota_1": @@ -179,27 +167,19 @@ def _offset_int(im: dict) -> int: def _dedupe_same_offset(images: list[dict]) -> list[dict]: - """ - One esptool image per physical offset. Meshtastic maps both firmware-*.bin (app0) - and firmware-*.factory.bin to the same ota_0 slot — keep factory, drop the duplicate. - """ + """One esptool image per physical offset (prefer non-optional, then lexicographic file).""" buckets: dict[int, list[dict]] = {} for im in images: buckets.setdefault(_offset_int(im), []).append(im) - def rank(im: dict) -> tuple: - f = im["file"] - opt = im.get("optional") is True - if re.search(r"\.factory\.bin$", f, re.I): - return (0, 0 if not opt else 1, f) - if not opt: - return (1, 0, f) - return (2, 0, f) - out: list[dict] = [] for off in sorted(buckets): group = buckets[off] - out.append(group[0] if len(group) == 1 else min(group, key=rank)) + out.append( + group[0] + if len(group) == 1 + else min(group, key=lambda im: (im.get("optional") is True, str(im.get("file", "")))) + ) return out @@ -233,20 +213,11 @@ def emit_from_mt(build_dir: str, mt: dict) -> dict | None: off = part_offset_for_slot(parts, str(part_name)) if off is None: continue - opt = bool( - fname.startswith("littlefs-") - or re.match(r"^mt-.+-ota\.bin$", fname, re.I) - or ( - re.match(r"^firmware-.+\.bin$", fname, re.I) - and not re.search(r"\.factory\.bin$", fname, re.I) - ) - ) + # Only LittleFS is optional in the bundle; Mesh Forge flashes it when the user enables + # "Reset device storage". Bootloader, partitions, app, and BLE OTA are always flashed when present. + opt = bool(fname.startswith("littlefs-")) add(fname, off, optional=opt) - factory_bins = sorted(n for n in names if re.match(r"^firmware-.+\.factory\.bin$", n, re.I)) - if factory_bins: - add(factory_bins[0], factory_app_offset(parts)) - for n in sorted(names): if n.startswith("littlefs-") and n.endswith(".bin"): off = spiffs_offset(parts) @@ -257,7 +228,7 @@ def emit_from_mt(build_dir: str, mt: dict) -> dict | None: if re.match(r"^mt-.+-ota\.bin$", n, re.I): off = ota1_offset(parts) if off is not None: - add(n, off, optional=True) + add(n, off, optional=False) if not images: return None diff --git a/scripts/stage-fw-bundle-docs.sh b/scripts/stage-fw-bundle-docs.sh new file mode 100755 index 0000000..259f151 --- /dev/null +++ b/scripts/stage-fw-bundle-docs.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Copy README and license files from repo root into the firmware tarball stage when present. +set -euo pipefail +ROOT="${1:?root dir}" +STAGE="${2:?stage dir}" + +for f in \ + README \ + README.md \ + README.rst \ + README.txt \ + readme.md \ + LICENSE \ + LICENSE.md \ + LICENSE.txt \ + COPYING \ + COPYRIGHT; do + if [[ -f "$ROOT/$f" ]]; then + cp -a "$ROOT/$f" "$STAGE/" + fi +done diff --git a/src/components/EspFlasher.tsx b/src/components/EspFlasher.tsx index 9bb8a83..71c5556 100644 --- a/src/components/EspFlasher.tsx +++ b/src/components/EspFlasher.tsx @@ -1,12 +1,24 @@ import { Button } from "@/components/ui/button" -import { Check, CheckCircle2, X } from "lucide-react" -import { useCallback, useEffect, useMemo, useState } from "react" +import { + CheckCircle2, + Download, + ExternalLink, + Github, + Link2, + Mail, + PlayCircle, + Share2, + Smartphone, + X, +} from "lucide-react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" -import { buildFlashParts, flashInstallRowsFromManifest, manifestFromMap } from "../lib/espFlashLayout" +import { buildFlashParts, manifestFromMap } from "../lib/espFlashLayout" import { ensureSerialPortClosed, + ESP_FLASH_WEB_BAUD, isSerialUserCancelledError, - pulseUsbBootloaderPort, + pulseUsbBootloaderOnPort, runEspFlash, type FlashPhase, } from "../lib/espFlashRun" @@ -42,34 +54,43 @@ type EspFlasherProps = { bundleUrl: string /** PlatformIO env for the selected build; used if manifest omits targetFamily. */ targetEnv?: string | null - /** Primary CTA label (default matches standalone copy). */ flashButtonLabel?: string flashBusyLabel?: string flashButtonSize?: "default" | "lg" className?: string - /** Tighter copy when shown in the repo hero. */ - condensed?: boolean + /** Firmware source repo tree at this ref (no target / flash path segment). */ + githubRepoTreeHref?: string | null + /** Mesh Forge GitHub Actions run for this build. */ + githubActionsRunHref?: string | null + /** Download firmware bundle (icon button below controls). */ + onDownloadBundle?: (() => void) | null + /** Current page URL for the share popover (exact flasher view). */ + sharePageUrl?: string | null + /** Omit outer card chrome when parent already provides the bordered container. */ + embedded?: boolean } export default function EspFlasher({ bundleUrl, targetEnv = null, - flashButtonLabel = "Connect serial & flash", - flashBusyLabel = "Flashing…", - flashButtonSize = "default", + flashButtonLabel = "Flash", + flashBusyLabel = "Writing…", + flashButtonSize = "lg", className = "", - condensed = false, + githubRepoTreeHref = null, + githubActionsRunHref = null, + onDownloadBundle = null, + sharePageUrl = null, + embedded = false, }: EspFlasherProps) { const [busy, setBusy] = useState(false) - const [eraseAll, setEraseAll] = useState(false) - const [baud, setBaud] = useState(921600) + const shareDialogRef = useRef(null) + const [resetDeviceStorage, setResetDeviceStorage] = useState(false) const [layoutPreview, setLayoutPreview] = useState(null) const [flashProgress, setFlashProgress] = useState(null) const [bundleLoadError, setBundleLoadError] = useState(null) - const [dfuPulsedOnce, setDfuPulsedOnce] = useState(false) useEffect(() => { - setDfuPulsedOnce(false) setLayoutPreview(null) setBundleLoadError(null) let cancelled = false @@ -102,11 +123,6 @@ export default function EspFlasher({ const flashBlockedReason = unsupportedFlashMessage(resolvedFamily) const canEspFlash = flashBlockedReason === null - const installPlanRows = useMemo( - () => (layoutPreview ? flashInstallRowsFromManifest(layoutPreview, eraseAll) : []), - [layoutPreview, eraseAll] - ) - const prepareBundle = useCallback(async () => { const res = await fetch(bundleUrl) if (!res.ok) throw new Error(`Download failed: ${res.status}`) @@ -132,9 +148,12 @@ export default function EspFlasher({ let port: SerialPort | undefined try { port = await navigator.serial.requestPort() + setFlashProgress({ kind: "indeterminate", label: "USB bootloader reset…" }) + await pulseUsbBootloaderOnPort(port) + setFlashProgress({ kind: "indeterminate", label: "Downloading firmware…" }) const files = await prepareBundle() - const parts = buildFlashParts(files, { eraseAll }) + const parts = buildFlashParts(files, { resetDeviceStorage }) if (!parts) { toast.error("Could not detect flash layout from bundle") return @@ -143,8 +162,8 @@ export default function EspFlasher({ await runEspFlash({ port, parts, - baud, - eraseAll, + baud: ESP_FLASH_WEB_BAUD, + eraseAll: false, onPhase: phase => { setFlashProgress({ kind: "indeterminate", label: PHASE_LABEL[phase] }) }, @@ -174,134 +193,63 @@ export default function EspFlasher({ setFlashProgress(null) } } - }, [baud, eraseAll, prepareBundle, canEspFlash, flashBlockedReason]) + }, [resetDeviceStorage, prepareBundle, canEspFlash, flashBlockedReason]) - const enterDfuMode = useCallback(async () => { - try { - await pulseUsbBootloaderPort() - setDfuPulsedOnce(true) - toast.success("1200-baud touch sent", { - description: - "If the device re-enumerated, pick the serial port and flash. If not, hold BOOT, tap RST, then try again.", - }) - } catch (e) { - if (isSerialUserCancelledError(e)) { - return - } - toast.error(e instanceof Error ? e.message : String(e)) - } + const shareUrlTrimmed = useMemo(() => sharePageUrl?.trim() ?? "", [sharePageUrl]) + const canNativeShare = typeof navigator !== "undefined" && typeof navigator.share === "function" + + const closeShareDialog = useCallback(() => { + shareDialogRef.current?.close() }, []) - const familyLine = - resolvedFamily !== "esp32" || layoutPreview?.targetFamily - ? `Target: ${resolvedFamily}${layoutPreview?.platform ? ` · ${layoutPreview.platform.trim()}` : ""}` - : null + const hasIconRow = + Boolean(githubRepoTreeHref?.trim()) || + Boolean(githubActionsRunHref?.trim()) || + typeof onDownloadBundle === "function" || + Boolean(shareUrlTrimmed) + + const copyShareLink = useCallback(async () => { + if (!shareUrlTrimmed) return + try { + await navigator.clipboard.writeText(shareUrlTrimmed) + toast.success("Link copied") + closeShareDialog() + } catch { + toast.error("Could not copy link") + } + }, [shareUrlTrimmed, closeShareDialog]) + + const openNativeShare = useCallback(async () => { + if (!shareUrlTrimmed || !canNativeShare) return + try { + await navigator.share({ + url: shareUrlTrimmed, + title: "Mesh Forge Web Flasher", + }) + closeShareDialog() + } catch (e) { + if (e instanceof Error && e.name === "AbortError") return + toast.error("Share was cancelled or failed") + } + }, [shareUrlTrimmed, canNativeShare, closeShareDialog]) + + const rootClass = embedded + ? `space-y-3 ${className}`.trim() + : `rounded-lg border border-slate-700 bg-slate-900/50 p-4 space-y-3 ${className}`.trim() return ( -
- {condensed ? null : ( - <> -

USB firmware flash (Web Serial)

-

- esptool-js for ESP32-class layouts. Put the board in ROM serial-download mode if needed — - use Enter DFU mode for the USB CDC 1200-baud touch when supported. -

- - )} - {condensed ? ( -

- Chromium Web Serial + esptool-js (ESP32-class). Verify the flash map — wrong images can brick hardware. -

- ) : null} - +
{bundleLoadError ? (

Could not prefetch bundle: {bundleLoadError}

) : null} - {familyLine ?

{familyLine}

: null} - {flashBlockedReason ? (

{flashBlockedReason}

) : null} - {layoutPreview ? ( -
- - - - - - - - - - {installPlanRows.map((row, i) => ( - - - - - - ))} - -
- Image - - Offset - - Install -
{row.file}{row.offsetHex} - {row.willInstall ? ( - - - Yes - - ) : ( - - - No - - )} -
-

- Optional images are skipped unless Full chip erase is checked. -

-
- ) : ( -

- Default layout: bootloader @ 0x1000, partitions @ 0x8000, app @ 0x10000, optional boot_app0 @ 0xe000—or single - firmware.bin @ 0x0. With flash-manifest.json, offsets come from the - bundle. -

- )} - -
- - -
- -
+
- + +
+ + Reset device storage +
+ {resetDeviceStorage ? ( +

+ Warning: all user data on the device will be deleted + when you flash (channels, preferences, and stored files). +

+ ) : null} + + {hasIconRow ? ( +
+ {githubRepoTreeHref?.trim() ? ( + + + View source on GitHub + + ) : null} + {githubActionsRunHref?.trim() ? ( + + + View build on GitHub + + ) : null} + {typeof onDownloadBundle === "function" ? ( + + ) : null} + {shareUrlTrimmed ? ( + <> + + { + if (e.target === e.currentTarget) closeShareDialog() + }} + > +
{ + if (e.target === e.currentTarget) closeShareDialog() + }} + > +
e.stopPropagation()} + > +
+
+ +
+
+ +
+
+

Share Web Flasher

+

+ Same firmware view in Mesh Forge — repo, ref, target, and bundle. +

+
+
+
+
+
+

Link

+

+ {shareUrlTrimmed} +

+
+
+ + {canNativeShare ? ( + + ) : null} +
+
+

Open or post

+
+ + + + Email + + + + 𝕏 + + X + + + + Bluesky + +
+
+
+
+
+
+ + ) : null} +
+ ) : null} + {flashProgress ? ( flashProgress.kind === "complete" ? (
diff --git a/src/lib/espFlashLayout.ts b/src/lib/espFlashLayout.ts index 8d1131c..644e74e 100644 --- a/src/lib/espFlashLayout.ts +++ b/src/lib/espFlashLayout.ts @@ -2,22 +2,17 @@ import { findInTar, parseFlashManifest, type FlashManifest, type FlashManifestIm export type FlashPart = { data: Uint8Array; address: number; name: string } -export type FlashInstallPlanRow = { - file: string - offset: number - offsetHex: string - optional: boolean - willInstall: boolean -} - function manifestImageOffset(im: FlashManifestImage): number | null { const addr = typeof im.offset === 'string' ? parseInt(im.offset, 0) : Number(im.offset) return Number.isFinite(addr) ? addr : null } export type BuildFlashPartsOptions = { - /** When false, manifest rows with optional:true are omitted. */ - eraseAll?: boolean + /** + * When true, flash optional LittleFS images from the manifest (wipes Meshtastic storage on device). + * Bootloader, partitions, firmware, and other non-LittleFS images are always included when present. + */ + resetDeviceStorage?: boolean } function sortFlashParts(parts: FlashPart[]): FlashPart[] { @@ -29,9 +24,14 @@ function tarBasename(path: string): string { return parts[parts.length - 1] ?? path } +function isLittlefsManifestFile(file: string): boolean { + const base = tarBasename(file) + return base.toLowerCase().startsWith('littlefs-') && base.toLowerCase().endsWith('.bin') +} + /** * PlatformIO projects (e.g. Meshtastic) often emit versioned names like - * firmware-heltec-v3-2.7.20.factory.bin instead of firmware.factory.bin. + * firmware-heltec-v3-2.7.20.bin (split app image; merged factory.bin is not bundled for USB flash). */ function resolveVersionedFirmwareApp( files: Map @@ -52,9 +52,6 @@ function resolveVersionedFirmwareApp( list.push({ base, data }) } - const factory = list.find(e => /^firmware-.+\.factory\.bin$/i.test(e.base)) - if (factory) return { data: factory.data, name: factory.base } - const app = list.find( e => /^firmware-.+\.bin$/i.test(e.base) && !/\.factory\.bin$/i.test(e.base) ) @@ -63,31 +60,12 @@ function resolveVersionedFirmwareApp( return undefined } -/** Sorted install plan for UI; `willInstall` matches `buildFlashParts` optional + eraseAll rules. */ -export function flashInstallRowsFromManifest(m: FlashManifest, eraseAll: boolean): FlashInstallPlanRow[] { - const rows: FlashInstallPlanRow[] = [] - for (const im of m.images) { - const offset = manifestImageOffset(im) - if (offset === null) continue - const optional = im.optional === true - const willInstall = !optional || eraseAll - rows.push({ - file: im.file, - offset, - offsetHex: `0x${offset.toString(16)}`, - optional, - willInstall, - }) - } - return rows.sort((a, b) => a.offset - b.offset) -} - /** Build ordered flash parts from a flat map (tar paths or bare filenames → bytes). */ export function buildFlashParts( files: Map, options: BuildFlashPartsOptions = {} ): FlashPart[] | null { - const eraseAll = options.eraseAll ?? false + const resetDeviceStorage = options.resetDeviceStorage ?? false const manifestRaw = findInTar(files, 'flash-manifest.json') if (manifestRaw) { @@ -96,7 +74,7 @@ export function buildFlashParts( if (m) { const out: FlashPart[] = [] for (const img of m.images) { - if (!eraseAll && img.optional === true) continue + if (img.optional === true && isLittlefsManifestFile(img.file) && !resetDeviceStorage) continue const data = findInTar(files, img.file) if (!data) return null const addr = typeof img.offset === 'string' ? parseInt(img.offset, 0) : Number(img.offset) @@ -110,16 +88,12 @@ export function buildFlashParts( const bootloader = findInTar(files, 'bootloader.bin') const partitions = findInTar(files, 'partitions.bin') const bootApp0 = findInTar(files, 'boot_app0.bin') - const factoryExact = findInTar(files, 'firmware.factory.bin') const firmwareExact = findInTar(files, 'firmware.bin') const versioned = resolveVersionedFirmwareApp(files) let app: Uint8Array | undefined let appName: string | undefined - if (factoryExact) { - app = factoryExact - appName = 'firmware.factory.bin' - } else if (firmwareExact) { + if (firmwareExact) { app = firmwareExact appName = 'firmware.bin' } else if (versioned) { diff --git a/src/lib/espFlashRun.ts b/src/lib/espFlashRun.ts index 750054b..5b4410d 100644 --- a/src/lib/espFlashRun.ts +++ b/src/lib/espFlashRun.ts @@ -191,16 +191,18 @@ export async function runEspFlash(options: { await transport.disconnect() } +/** Conservative default for `runEspFlash` over Web Serial (fewer cable/hub issues than 921600). */ +export const ESP_FLASH_WEB_BAUD = 115200 + /** Match MeshCore `lib/dfu.js` CDC touch timing (1200 baud → close → wait for re-enumeration). */ const CDC_TOUCH_OPEN_MS = 100 const CDC_TOUCH_AFTER_CLOSE_MS = 1500 -/** Classic ESP32/S3 (and many nRF CDC) USB bootloader entry: open port at 1200 baud, then close and wait. */ -export async function pulseUsbBootloaderPort(): Promise { - if (!("serial" in navigator)) { - throw new Error("Web Serial is not available") - } - const port = await navigator.serial.requestPort() +/** + * USB CDC “1200 baud” bootloader entry on a port the user already picked (ESP32-class). + * Closes the port when done; wait before returning so the device can re-enumerate. + */ +export async function pulseUsbBootloaderOnPort(port: SerialPort): Promise { await ensureSerialPortClosed(port) try { await openSerialPortWithRecovery(port, { baudRate: 1200 }) @@ -210,3 +212,12 @@ export async function pulseUsbBootloaderPort(): Promise { } await sleepMs(CDC_TOUCH_AFTER_CLOSE_MS) } + +/** Prompt for a serial port, then run {@link pulseUsbBootloaderOnPort}. */ +export async function pulseUsbBootloaderPort(): Promise { + if (!("serial" in navigator)) { + throw new Error("Web Serial is not available") + } + const port = await navigator.serial.requestPort() + await pulseUsbBootloaderOnPort(port) +} diff --git a/src/lib/untarGz.ts b/src/lib/untarGz.ts index f8f1cf5..cc625e4 100644 --- a/src/lib/untarGz.ts +++ b/src/lib/untarGz.ts @@ -48,7 +48,7 @@ export function findInTar(files: Map, filename: string): Uin export type FlashManifestImage = { file: string offset: number | string - /** When true, Mesh Forge skips this image unless the user enables full chip erase. */ + /** When true on LittleFS rows, Mesh Forge skips unless the user enables Reset device storage. */ optional?: boolean role?: string } diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index 8445169..11d618b 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -13,7 +13,7 @@ import { type ImgHTMLAttributes, } from "react" import ReactMarkdown from "react-markdown" -import { Link, useNavigate, useParams } from "react-router-dom" +import { Link, useLocation, useNavigate, useParams } from "react-router-dom" import rehypeRaw from "rehype-raw" import rehypeSanitize from "rehype-sanitize" import remarkGfm from "remark-gfm" @@ -31,6 +31,7 @@ const meshForgeWorkflowUrl = `https://github.com/${MESH_FORGE_ACTIONS_REPO}/acti export default function RepoPage() { const navigate = useNavigate() + const location = useLocation() const params = useParams<{ owner: string; repo: string; "*": string }>() const ownerParam = params.owner ?? "" const repoParam = params.repo ?? "" @@ -45,6 +46,11 @@ export default function RepoPage() { const isFlashView = flashFromUrl const hasRef = Boolean(sourceRef) + const shareFlashPageUrl = useMemo(() => { + if (typeof window === "undefined") return "" + return `${window.location.origin}${location.pathname}${location.search}` + }, [location.pathname, location.search]) + const tagData = useQuery(api.repoTags.get, owner && repo ? { owner, repo } : "skip") const refreshTags = useAction(api.repoTags.refresh) const resolveRef = useAction(api.repoScans.resolveRefToSha) @@ -414,139 +420,194 @@ export default function RepoPage() {
) - const ciAndFlasherEl = ( -
- {showCiCard ? ( -
- {build.githubRunId || build.status === "failed" ? ( -
- {build.githubRunId ? ( - - View run on GitHub - - ) : ( - - Mesh Forge workflow on GitHub - - )} -
- ) : null} - {buildInProgress ? ( -
- {(() => { - const step = build.ciProgressStep - const total = build.ciProgressTotal - const label = build.ciProgressLabel - const hasSteps = - typeof step === "number" && typeof total === "number" && total > 0 && step >= 1 && step <= total - const pct = hasSteps ? Math.min(100, Math.round((step / total) * 100)) : null - return ( - <> -
- {pct !== null ? ( -
- ) : ( -
- )} -
-

- {hasSteps ? ( - <> - Step {step} of {total} - {label ? ` · ${label}` : ""} - - ) : ( - <>Waiting for CI progress… - )} -

- - ) - })()} -
- ) : null} - {build.status === "failed" && build.errorSummary ? ( -
- {(() => { - const { headline, body } = buildFailurePresentation(build.errorSummary) - return ( - <> -

{headline}

- {body ?

{body}

: null} -
- Technical details -
-                        {build.errorSummary.length > 2500 ? `…${build.errorSummary.slice(-2500)}` : build.errorSummary}
-                      
-
- - ) - })()} -
- ) : null} - {build.status === "succeeded" ? ( - - ) : null} -
- ) : null} + const showFlashUsbPanel = Boolean(flashUrl) + const showCiInner = Boolean(showCiCard && build && !showFlashUsbPanel) - {flashPrep === "loading" ?

Preparing USB flasher…

: null} - {flashPrep === "error" ? ( -

- Could not load a signed URL for flashing. Use Download bundle if you need the file. -

- ) : null} - {flashUrl ? ( + const ciAndFlasherEl = ( +
+ {showFlashUsbPanel ? ( void download()} + sharePageUrl={shareFlashPageUrl} /> + ) : showCiInner ? ( + <> + {build.status === "failed" ? ( +
+ {build.githubRunId ? ( + + View run on GitHub + + ) : ( + + Mesh Forge workflow on GitHub + + )} +
+ ) : null} + {buildInProgress ? ( +
+ {(() => { + const step = build.ciProgressStep + const total = build.ciProgressTotal + const label = build.ciProgressLabel + const hasSteps = + typeof step === "number" && typeof total === "number" && total > 0 && step >= 1 && step <= total + const pct = hasSteps ? Math.min(100, Math.round((step / total) * 100)) : null + return ( + <> +
+ {pct !== null ? ( +
+ ) : ( +
+ )} +
+

+ {hasSteps ? ( + <> + Step {step} of {total} + {label ? ` · ${label}` : ""} + + ) : ( + <>Waiting for CI progress… + )} +

+ {build.githubRunId ? ( + + View run on GitHub + + ) : null} + + ) + })()} +
+ ) : null} + {build.status === "failed" && build.errorSummary ? ( +
+ {(() => { + const { headline, body } = buildFailurePresentation(build.errorSummary) + return ( + <> +

{headline}

+ {body ?

{body}

: null} +
+ Technical details +
+                            {build.errorSummary.length > 2500
+                              ? `…${build.errorSummary.slice(-2500)}`
+                              : build.errorSummary}
+                          
+
+ + ) + })()} +
+ ) : null} + {build.status === "succeeded" && !flashUrl ? ( + <> + {flashPrep === "loading" ? ( +

Preparing USB flasher…

+ ) : null} + {flashPrep === "error" ? ( +

+ Could not load a signed URL for flashing. Use Download bundle if you need the + file. +

+ ) : null} + {flashPrep !== "loading" && flashPrep !== "error" ? ( + + ) : null} + + ) : null} + ) : null}
) return ( -
-
- {isFlashView ? ( -
-
-

- {owner}/{repo}@{effectiveRef} - {resolvedTargetEnv ? ` ${resolvedTargetEnv}` : ""} Flasher +
+ {isFlashView ? ( +
+
+
+
+

+ Web Flasher +

+ + ← Back to repository + +
+

+ {owner} + / + {repo}

- - ← Repository - -
+
+
+
+
+ Source ref +
+
{effectiveRef}
+
+
+
+ Target +
+
+ {resolvedTargetEnv ?? "—"} +
+
+
+
+ {statusStripEl} {ciAndFlasherEl}
- ) : ( +

+ ) : ( +