mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-08 01:43:05 +02:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+21
@@ -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
|
||||
+294
-139
@@ -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<HTMLDialogElement>(null)
|
||||
const [resetDeviceStorage, setResetDeviceStorage] = useState(false)
|
||||
const [layoutPreview, setLayoutPreview] = useState<FlashManifest | null>(null)
|
||||
const [flashProgress, setFlashProgress] = useState<FlashProgress | null>(null)
|
||||
const [bundleLoadError, setBundleLoadError] = useState<string | null>(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 (
|
||||
<div className={`rounded-lg border border-slate-700 bg-slate-900/50 p-4 space-y-3 ${className}`.trim()}>
|
||||
{condensed ? null : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold text-white">USB firmware flash (Web Serial)</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
esptool-js for ESP32-class layouts. Put the board in <strong>ROM serial-download mode</strong> if needed —
|
||||
use <strong>Enter DFU mode</strong> for the USB CDC 1200-baud touch when supported.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{condensed ? (
|
||||
<p className="text-sm text-slate-400">
|
||||
Chromium Web Serial + esptool-js (ESP32-class). Verify the flash map — wrong images can brick hardware.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className={rootClass}>
|
||||
{bundleLoadError ? (
|
||||
<p className="text-xs text-amber-300/90">Could not prefetch bundle: {bundleLoadError}</p>
|
||||
) : null}
|
||||
|
||||
{familyLine ? <p className="text-xs font-mono text-slate-400">{familyLine}</p> : null}
|
||||
|
||||
{flashBlockedReason ? (
|
||||
<p className="text-sm text-amber-200/90 rounded-md border border-amber-800/40 bg-amber-950/30 px-3 py-2">
|
||||
{flashBlockedReason}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{layoutPreview ? (
|
||||
<div className="space-y-1">
|
||||
<table className="w-full text-left text-xs font-mono text-slate-300 border border-slate-600 rounded-md overflow-hidden">
|
||||
<thead className="bg-slate-800/80 text-slate-400">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 font-medium" scope="col">
|
||||
Image
|
||||
</th>
|
||||
<th className="px-2 py-1.5 font-medium w-30" scope="col">
|
||||
Offset
|
||||
</th>
|
||||
<th className="px-2 py-1.5 font-medium w-24 text-center" scope="col">
|
||||
Install
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{installPlanRows.map((row, i) => (
|
||||
<tr key={`${row.offset}-${i}-${row.file}`} className="border-t border-slate-700/80">
|
||||
<td className="px-2 py-1.5 break-all">{row.file}</td>
|
||||
<td className="px-2 py-1.5 text-slate-400 whitespace-nowrap">{row.offsetHex}</td>
|
||||
<td className="px-2 py-1.5 text-center align-middle">
|
||||
{row.willInstall ? (
|
||||
<span className="inline-flex items-center justify-center text-emerald-400" title="Will flash">
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
<span className="sr-only">Yes</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center justify-center text-red-400" title="Skipped (enable full chip erase)">
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
<span className="sr-only">No</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Optional images are skipped unless <span className="text-slate-400">Full chip erase</span> is checked.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
Default layout: bootloader @ 0x1000, partitions @ 0x8000, app @ 0x10000, optional boot_app0 @ 0xe000—or single
|
||||
firmware.bin @ 0x0. With <code className="text-slate-400">flash-manifest.json</code>, offsets come from the
|
||||
bundle.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<label className="text-sm text-slate-300 flex items-center gap-2">
|
||||
Baud
|
||||
<select
|
||||
className="bg-slate-800 border border-slate-600 rounded px-2 py-1 text-white"
|
||||
value={baud}
|
||||
onChange={e => setBaud(Number(e.target.value))}
|
||||
disabled={!canEspFlash}
|
||||
>
|
||||
<option value={115200}>115200</option>
|
||||
<option value={460800}>460800</option>
|
||||
<option value={921600}>921600</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-sm text-slate-300 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eraseAll}
|
||||
onChange={e => setEraseAll(e.target.checked)}
|
||||
disabled={!canEspFlash}
|
||||
/>
|
||||
Full chip erase (destructive)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 justify-center sm:justify-start">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
size={flashButtonSize}
|
||||
@@ -311,11 +259,218 @@ export default function EspFlasher({
|
||||
>
|
||||
{busy ? flashBusyLabel : flashButtonLabel}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => void enterDfuMode()}>
|
||||
{dfuPulsedOnce ? "Enter DFU mode (again)" : "Enter DFU mode"}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={resetDeviceStorage}
|
||||
aria-label="Reset device storage"
|
||||
disabled={!canEspFlash}
|
||||
onClick={() => setResetDeviceStorage(v => !v)}
|
||||
className={`relative inline-flex h-7 w-12 shrink-0 cursor-pointer rounded-full border border-slate-600 transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
resetDeviceStorage ? "bg-amber-600" : "bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-6 w-6 translate-y-0.5 rounded-full bg-white shadow transition-transform duration-200 ease-out ${
|
||||
resetDeviceStorage ? "translate-x-[22px]" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-200">Reset device storage</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resetDeviceStorage ? (
|
||||
<p
|
||||
className="text-sm text-red-400 border border-red-500/40 bg-red-950/35 rounded-md px-3 py-2"
|
||||
role="status"
|
||||
>
|
||||
<strong className="font-semibold text-red-300">Warning:</strong> all user data on the device will be deleted
|
||||
when you flash (channels, preferences, and stored files).
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasIconRow ? (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
{githubRepoTreeHref?.trim() ? (
|
||||
<a
|
||||
href={githubRepoTreeHref.trim()}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-600 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
title="View source on GitHub"
|
||||
>
|
||||
<Github className="h-5 w-5" aria-hidden />
|
||||
<span className="sr-only">View source on GitHub</span>
|
||||
</a>
|
||||
) : null}
|
||||
{githubActionsRunHref?.trim() ? (
|
||||
<a
|
||||
href={githubActionsRunHref.trim()}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-600 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
title="View build on GitHub"
|
||||
>
|
||||
<PlayCircle className="h-5 w-5" aria-hidden />
|
||||
<span className="sr-only">View build on GitHub</span>
|
||||
</a>
|
||||
) : null}
|
||||
{typeof onDownloadBundle === "function" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDownloadBundle()}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-600 text-slate-300 hover:bg-slate-800 hover:text-white disabled:opacity-50"
|
||||
title="Download bundle"
|
||||
>
|
||||
<Download className="h-5 w-5" aria-hidden />
|
||||
<span className="sr-only">Download bundle</span>
|
||||
</button>
|
||||
) : null}
|
||||
{shareUrlTrimmed ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-slate-600 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
title="Share this page"
|
||||
onClick={() => shareDialogRef.current?.showModal()}
|
||||
>
|
||||
<Share2 className="h-5 w-5" aria-hidden />
|
||||
<span className="sr-only">Share this page</span>
|
||||
</button>
|
||||
<dialog
|
||||
ref={shareDialogRef}
|
||||
className="fixed inset-0 z-50 m-0 h-full max-h-none w-full max-w-none border-0 bg-transparent p-0 text-slate-100 backdrop:bg-slate-950/65 backdrop:backdrop-blur-[2px]"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) closeShareDialog()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-full w-full items-center justify-center p-4 sm:p-8"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) closeShareDialog()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-slate-600/70 bg-slate-950 shadow-[0_0_0_1px_rgba(255,255,255,0.04),0_25px_80px_-12px_rgba(0,0,0,0.85)]"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent via-amber-500/50 to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<header className="border-b border-slate-800/90 bg-slate-900/40 px-5 pb-4 pt-5 sm:px-6 sm:pt-6">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3 inline-flex h-9 w-9 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-800 hover:text-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
|
||||
onClick={closeShareDialog}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
<div className="flex gap-4 pr-10">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-linear-to-br from-amber-500/20 to-cyan-600/10 text-amber-400 ring-1 ring-amber-500/25">
|
||||
<Share2 className="h-6 w-6" aria-hidden />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight text-slate-50">Share Web Flasher</h2>
|
||||
<p className="text-sm leading-relaxed text-slate-400">
|
||||
Same firmware view in Mesh Forge — repo, ref, target, and bundle.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-5 px-5 py-5 sm:px-6 sm:py-6">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">Link</p>
|
||||
<p className="mt-2 max-h-24 overflow-y-auto rounded-xl border border-slate-700/80 bg-slate-900/60 px-3.5 py-3 font-mono text-[12px] leading-snug text-slate-300 wrap-anywhere">
|
||||
{shareUrlTrimmed}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="h-11 w-full gap-2 bg-amber-600 text-white hover:bg-amber-500"
|
||||
onClick={() => void copyShareLink()}
|
||||
>
|
||||
<Link2 className="h-4 w-4" aria-hidden />
|
||||
Copy link
|
||||
</Button>
|
||||
{canNativeShare ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-11 w-full gap-2 border-slate-600 bg-slate-900/50 text-slate-100 hover:bg-slate-800"
|
||||
onClick={() => void openNativeShare()}
|
||||
>
|
||||
<Smartphone className="h-4 w-4" aria-hidden />
|
||||
Share via this device
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-slate-500">Open or post</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title="Open in new tab"
|
||||
className="h-11 gap-2 border-slate-600 bg-slate-900/40 text-slate-200 hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
window.open(shareUrlTrimmed, "_blank", "noopener,noreferrer")
|
||||
closeShareDialog()
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 shrink-0 opacity-90" aria-hidden />
|
||||
<span className="truncate">New tab</span>
|
||||
</Button>
|
||||
<a
|
||||
href={`mailto:?subject=${encodeURIComponent("Mesh Forge Web Flasher")}&body=${encodeURIComponent(shareUrlTrimmed)}`}
|
||||
title="Email this link"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-600 bg-slate-900/40 px-3 text-sm font-medium text-slate-200 transition-colors hover:bg-slate-800"
|
||||
onClick={closeShareDialog}
|
||||
>
|
||||
<Mail className="h-4 w-4 shrink-0 opacity-90" aria-hidden />
|
||||
<span className="truncate">Email</span>
|
||||
</a>
|
||||
<a
|
||||
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrlTrimmed)}&text=${encodeURIComponent("Mesh Forge Web Flasher")}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Post on X"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-600 bg-slate-900/40 px-3 text-sm font-medium text-slate-200 transition-colors hover:bg-slate-800"
|
||||
onClick={closeShareDialog}
|
||||
>
|
||||
<span className="shrink-0 text-[13px] font-bold leading-none text-slate-100" aria-hidden>
|
||||
𝕏
|
||||
</span>
|
||||
<span className="truncate">X</span>
|
||||
</a>
|
||||
<a
|
||||
href={`https://bsky.app/intent/compose?text=${encodeURIComponent(shareUrlTrimmed)}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Post on Bluesky"
|
||||
className="inline-flex h-11 items-center justify-center gap-2 rounded-md border border-slate-600 bg-slate-900/40 px-3 text-sm font-medium text-slate-200 transition-colors hover:bg-slate-800"
|
||||
onClick={closeShareDialog}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-sky-400 shadow-[0_0_8px_rgba(56,189,248,0.5)]" aria-hidden />
|
||||
<span className="truncate">Bluesky</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{flashProgress ? (
|
||||
flashProgress.kind === "complete" ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-950/40 px-3 py-2">
|
||||
|
||||
+14
-40
@@ -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<string, Uint8Array>
|
||||
@@ -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<string, Uint8Array>,
|
||||
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) {
|
||||
|
||||
+17
-6
@@ -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<void> {
|
||||
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<void> {
|
||||
await ensureSerialPortClosed(port)
|
||||
try {
|
||||
await openSerialPortWithRecovery(port, { baudRate: 1200 })
|
||||
@@ -210,3 +212,12 @@ export async function pulseUsbBootloaderPort(): Promise<void> {
|
||||
}
|
||||
await sleepMs(CDC_TOUCH_AFTER_CLOSE_MS)
|
||||
}
|
||||
|
||||
/** Prompt for a serial port, then run {@link pulseUsbBootloaderOnPort}. */
|
||||
export async function pulseUsbBootloaderPort(): Promise<void> {
|
||||
if (!("serial" in navigator)) {
|
||||
throw new Error("Web Serial is not available")
|
||||
}
|
||||
const port = await navigator.serial.requestPort()
|
||||
await pulseUsbBootloaderOnPort(port)
|
||||
}
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ export function findInTar(files: Map<string, Uint8Array>, 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
|
||||
}
|
||||
|
||||
+182
-121
@@ -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() {
|
||||
</div>
|
||||
)
|
||||
|
||||
const ciAndFlasherEl = (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
{showCiCard ? (
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900/40 p-4 space-y-2 text-sm">
|
||||
{build.githubRunId || build.status === "failed" ? (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{build.githubRunId ? (
|
||||
<a
|
||||
className="text-cyan-400 hover:underline text-xs"
|
||||
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View run on GitHub
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
className="text-cyan-400 hover:underline text-xs"
|
||||
href={meshForgeWorkflowUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the Mesh Forge workflow to fix YAML or inspect recent runs."
|
||||
>
|
||||
Mesh Forge workflow on GitHub
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{buildInProgress ? (
|
||||
<div className="space-y-2 pt-1">
|
||||
{(() => {
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className={`relative h-2.5 w-full overflow-hidden rounded-full bg-slate-800/90 ${
|
||||
pct === null ? "animate-pulse" : ""
|
||||
}`}
|
||||
>
|
||||
{pct !== null ? (
|
||||
<div
|
||||
className="h-full rounded-full bg-linear-to-r from-cyan-600 to-emerald-500 transition-[width] duration-500 ease-out shadow-[0_0_12px_rgba(34,211,238,0.35)]"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="ci-progress-shimmer-x top-0 h-full w-[42%] rounded-full bg-linear-to-r from-cyan-700/85 via-emerald-400/95 to-cyan-700/85 shadow-[0_0_10px_rgba(52,211,153,0.4)]" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
{hasSteps ? (
|
||||
<>
|
||||
Step {step} of {total}
|
||||
{label ? ` · ${label}` : ""}
|
||||
</>
|
||||
) : (
|
||||
<>Waiting for CI progress…</>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
{build.status === "failed" && build.errorSummary ? (
|
||||
<div className="space-y-2 text-xs">
|
||||
{(() => {
|
||||
const { headline, body } = buildFailurePresentation(build.errorSummary)
|
||||
return (
|
||||
<>
|
||||
<p className="font-medium text-slate-200">{headline}</p>
|
||||
{body ? <p className="text-slate-400 leading-relaxed">{body}</p> : null}
|
||||
<details className="text-slate-500">
|
||||
<summary className="cursor-pointer select-none hover:text-slate-400">Technical details</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap wrap-break-word text-[11px] text-red-300/90">
|
||||
{build.errorSummary.length > 2500 ? `…${build.errorSummary.slice(-2500)}` : build.errorSummary}
|
||||
</pre>
|
||||
</details>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
{build.status === "succeeded" ? (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void download()}>
|
||||
Download bundle
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
const showFlashUsbPanel = Boolean(flashUrl)
|
||||
const showCiInner = Boolean(showCiCard && build && !showFlashUsbPanel)
|
||||
|
||||
{flashPrep === "loading" ? <p className="text-sm text-slate-400">Preparing USB flasher…</p> : null}
|
||||
{flashPrep === "error" ? (
|
||||
<p className="text-sm text-amber-200/90">
|
||||
Could not load a signed URL for flashing. Use <strong>Download bundle</strong> if you need the file.
|
||||
</p>
|
||||
) : null}
|
||||
{flashUrl ? (
|
||||
const ciAndFlasherEl = (
|
||||
<div className="max-w-2xl space-y-4 text-sm">
|
||||
{showFlashUsbPanel ? (
|
||||
<EspFlasher
|
||||
bundleUrl={flashUrl}
|
||||
embedded
|
||||
bundleUrl={flashUrl!}
|
||||
targetEnv={resolvedTargetEnv}
|
||||
condensed
|
||||
flashButtonLabel="USB flash"
|
||||
flashBusyLabel="Writing…"
|
||||
flashButtonSize="lg"
|
||||
className="border-amber-900/50 bg-amber-950/25"
|
||||
githubRepoTreeHref={ghTree}
|
||||
githubActionsRunHref={
|
||||
build?.githubRunId
|
||||
? `https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`
|
||||
: null
|
||||
}
|
||||
onDownloadBundle={() => void download()}
|
||||
sharePageUrl={shareFlashPageUrl}
|
||||
/>
|
||||
) : showCiInner ? (
|
||||
<>
|
||||
{build.status === "failed" ? (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{build.githubRunId ? (
|
||||
<a
|
||||
className="text-cyan-400 hover:underline text-xs"
|
||||
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View run on GitHub
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
className="text-cyan-400 hover:underline text-xs"
|
||||
href={meshForgeWorkflowUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="No run ID — usually the workflow never started (e.g. dispatch rejected). Open the Mesh Forge workflow to fix YAML or inspect recent runs."
|
||||
>
|
||||
Mesh Forge workflow on GitHub
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{buildInProgress ? (
|
||||
<div className="space-y-2 pt-1">
|
||||
{(() => {
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className={`relative h-2.5 w-full overflow-hidden rounded-full bg-slate-800/90 ${
|
||||
pct === null ? "animate-pulse" : ""
|
||||
}`}
|
||||
>
|
||||
{pct !== null ? (
|
||||
<div
|
||||
className="h-full rounded-full bg-linear-to-r from-cyan-600 to-emerald-500 transition-[width] duration-500 ease-out shadow-[0_0_12px_rgba(34,211,238,0.35)]"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="ci-progress-shimmer-x top-0 h-full w-[42%] rounded-full bg-linear-to-r from-cyan-700/85 via-emerald-400/95 to-cyan-700/85 shadow-[0_0_10px_rgba(52,211,153,0.4)]" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
{hasSteps ? (
|
||||
<>
|
||||
Step {step} of {total}
|
||||
{label ? ` · ${label}` : ""}
|
||||
</>
|
||||
) : (
|
||||
<>Waiting for CI progress…</>
|
||||
)}
|
||||
</p>
|
||||
{build.githubRunId ? (
|
||||
<a
|
||||
className="inline-block text-cyan-400 hover:underline text-xs pt-0.5"
|
||||
href={`https://github.com/${MESH_FORGE_ACTIONS_REPO}/actions/runs/${build.githubRunId}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
View run on GitHub
|
||||
</a>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
{build.status === "failed" && build.errorSummary ? (
|
||||
<div className="space-y-2 text-xs">
|
||||
{(() => {
|
||||
const { headline, body } = buildFailurePresentation(build.errorSummary)
|
||||
return (
|
||||
<>
|
||||
<p className="font-medium text-slate-200">{headline}</p>
|
||||
{body ? <p className="text-slate-400 leading-relaxed">{body}</p> : null}
|
||||
<details className="text-slate-500">
|
||||
<summary className="cursor-pointer select-none hover:text-slate-400">Technical details</summary>
|
||||
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap wrap-break-word text-[11px] text-red-300/90">
|
||||
{build.errorSummary.length > 2500
|
||||
? `…${build.errorSummary.slice(-2500)}`
|
||||
: build.errorSummary}
|
||||
</pre>
|
||||
</details>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
{build.status === "succeeded" && !flashUrl ? (
|
||||
<>
|
||||
{flashPrep === "loading" ? (
|
||||
<p className="text-sm text-slate-400">Preparing USB flasher…</p>
|
||||
) : null}
|
||||
{flashPrep === "error" ? (
|
||||
<p className="text-sm text-amber-200/90">
|
||||
Could not load a signed URL for flashing. Use <strong>Download bundle</strong> if you need the
|
||||
file.
|
||||
</p>
|
||||
) : null}
|
||||
{flashPrep !== "loading" && flashPrep !== "error" ? (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void download()}>
|
||||
Download bundle
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`${isFlashView ? "max-w-3xl" : "max-w-6xl"} mx-auto px-6 py-10 text-slate-200`}>
|
||||
<section className="rounded-2xl border border-slate-700/90 bg-slate-950/90 p-6 md:p-8 shadow-xl shadow-black/30">
|
||||
{isFlashView ? (
|
||||
<div className="min-w-0 space-y-5">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl sm:text-2xl font-semibold tracking-tight text-slate-100 wrap-break-word">
|
||||
{owner}/{repo}@{effectiveRef}
|
||||
{resolvedTargetEnv ? ` ${resolvedTargetEnv}` : ""} Flasher
|
||||
<div className={`${isFlashView ? "max-w-4xl" : "max-w-6xl"} mx-auto px-6 py-10 text-slate-200`}>
|
||||
{isFlashView ? (
|
||||
<section className="rounded-2xl border border-slate-700/90 bg-slate-950/90 p-6 md:p-8 shadow-xl shadow-black/30">
|
||||
<div className="min-w-0 space-y-6 md:space-y-8">
|
||||
<header className="space-y-5 border-b border-slate-800/90 pb-8">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-500/90">
|
||||
Web Flasher
|
||||
</p>
|
||||
<Link
|
||||
to={backToRepoPath}
|
||||
className="text-sm text-cyan-400 hover:text-cyan-300 hover:underline underline-offset-4 shrink-0"
|
||||
>
|
||||
← Back to repository
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl font-bold tracking-tight text-white leading-[1.05] font-mono wrap-break-word">
|
||||
{owner}
|
||||
<span className="text-slate-600 font-light mx-1 sm:mx-1.5">/</span>
|
||||
{repo}
|
||||
</h1>
|
||||
<Link to={backToRepoPath} className="inline-block text-sm text-cyan-400 hover:underline">
|
||||
← Repository
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-3 max-w-2xl">
|
||||
<dl className="flex flex-col gap-2 text-sm sm:text-base">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<dt className="text-xs font-semibold uppercase tracking-wider text-slate-500 shrink-0 w-30">
|
||||
Source ref
|
||||
</dt>
|
||||
<dd className="font-mono text-slate-200 min-w-0 wrap-break-word">{effectiveRef}</dd>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<dt className="text-xs font-semibold uppercase tracking-wider text-slate-500 shrink-0 w-30">
|
||||
Target
|
||||
</dt>
|
||||
<dd className="font-mono text-amber-200/95 min-w-0 wrap-break-word">
|
||||
{resolvedTargetEnv ?? "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</header>
|
||||
{statusStripEl}
|
||||
{ciAndFlasherEl}
|
||||
</div>
|
||||
) : (
|
||||
</section>
|
||||
) : (
|
||||
<section className="rounded-2xl border border-slate-700/90 bg-slate-950/90 p-6 md:p-8 shadow-xl shadow-black/30">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_17.5rem] lg:gap-10 items-start
|
||||
[grid-template-areas:'repo-main''repo-aside''repo-readme']
|
||||
@@ -707,8 +768,8 @@ export default function RepoPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user