From a9e1e38b7498b4072c1e62d21987f0d80a21c34e Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Fri, 10 Apr 2026 17:34:12 -0700 Subject: [PATCH] Refactor firmware flashing process by updating EspFlasher component to support new props for GitHub links and download functionality. Modify buildFlashParts to handle optional LittleFS images based on resetDeviceStorage flag. Enhance emit-flash-manifest.py to exclude factory images from USB bundles. Update CI workflows to improve artifact staging and documentation generation. --- .github/workflows/custom_build.yml | 2 + .github/workflows/custom_build_test.yml | 2 + scripts/emit-flash-manifest.py | 55 +-- scripts/stage-fw-bundle-docs.sh | 21 ++ src/components/EspFlasher.tsx | 433 ++++++++++++++++-------- src/lib/espFlashLayout.ts | 54 +-- src/lib/espFlashRun.ts | 23 +- src/lib/untarGz.ts | 2 +- src/pages/RepoPage.tsx | 303 ++++++++++------- 9 files changed, 546 insertions(+), 349 deletions(-) create mode 100755 scripts/stage-fw-bundle-docs.sh 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}
- ) : ( +

+ ) : ( +