diff --git a/.cursor/rules/bun-package-manager.mdc b/.cursor/rules/bun-package-manager.mdc new file mode 100644 index 0000000..9034e90 --- /dev/null +++ b/.cursor/rules/bun-package-manager.mdc @@ -0,0 +1,17 @@ +--- +description: Use Bun for this repo (install, run, build); bunx for Convex CLI and one-off tools. +alwaysApply: true +--- + +# Package manager: Bun + +This project uses **Bun** (`bun.lock`). Do **not** use `npm install`, `npm run`, or `npx` for local work or mesh-forge CI unless there is no reasonable Bun equivalent. + +- **Install:** `bun install` +- **Scripts:** `bun run + + diff --git a/package.json b/package.json index 6b25e2d..bb69609 100644 --- a/package.json +++ b/package.json @@ -12,27 +12,31 @@ "scripts": { "generate:versions": "node scripts/generate-versions.js && biome format src/constants/versions.ts --write", "generate:architecture": "node scripts/generate-architecture-hierarchy.js", - "dev": "vike dev", - "build": "vike build", - "preview": "vike build && vike preview", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "smoke": "bun run build && bunx convex codegen", "lint": "prettier --write .", "lint:fix": "prettier --write .", - "deploy": "npx convex deploy --cmd 'bun run build' && wrangler deploy" + "deploy": "bunx convex deploy --cmd 'bun run build' && bunx wrangler deploy" }, "dependencies": { "@aws-sdk/client-s3": "^3.937.0", "@aws-sdk/s3-request-presigner": "^3.937.0", "@convex-dev/auth": "^0.0.90", - "@photonjs/cloudflare": "^0.1.9", "convex": "^1.29.3", "convex-helpers": "^0.1.106", + "esptool-js": "^0.6.0", + "fflate": "^0.8.2", "lucide-react": "^0.556.0", + "pako": "^2.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "sonner": "^2.0.7", - "vike": "^0.4.247", - "vike-photon": "^0.1.21", - "vike-react": "^0.6.13" + "react-markdown": "^10.1.0", + "react-router-dom": "^7.14.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "sonner": "^2.0.7" }, "devDependencies": { "@mdx-js/rollup": "^3.1.1", @@ -41,6 +45,7 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.17", "@types/node": "^24.10.1", + "@types/pako": "^2.0.4", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/pages/+Head.tsx b/pages/+Head.tsx deleted file mode 100644 index c6bbdcf..0000000 --- a/pages/+Head.tsx +++ /dev/null @@ -1,22 +0,0 @@ -// https://vike.dev/Head - -import appleTouchIconUrl from "@/assets/apple-touch-icon.png" -import favicon96x96Url from "@/assets/favicon-96x96.png" -import faviconIcoUrl from "@/assets/favicon.ico" -import faviconUrl from "@/assets/favicon.svg" -import logoUrl from "@/assets/logo.png" -import siteWebmanifestUrl from "@/assets/site.webmanifest" - -export function Head() { - return ( - <> - - - - - - - - - ) -} diff --git a/pages/+Layout.tsx b/pages/+Layout.tsx deleted file mode 100644 index c1b50ad..0000000 --- a/pages/+Layout.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import Footer from "@/components/Footer" -import Navbar from "@/components/Navbar" -import { ConvexAuthProvider } from "@convex-dev/auth/react" -import { ConvexReactClient } from "convex/react" -import { usePageContext } from "vike-react/usePageContext" -import "./Layout.css" -import "./tailwind.css" - -const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string) - -function ConditionalNavbar() { - const pageContext = usePageContext() - if (pageContext.urlPathname === "/") { - return null - } - return -} - -export default function Layout({ children }: { children: React.ReactNode }) { - return ( - -
- -
{children}
-
-
-
- ) -} diff --git a/pages/+config.ts b/pages/+config.ts deleted file mode 100644 index 97b5ed5..0000000 --- a/pages/+config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import vikeReact from "vike-react/config" -import type { Config } from "vike/types" - -// Default config (can be overridden by pages) -// https://vike.dev/config - -export default { - // https://vike.dev/head-tags - title: "Mesh Forge", - description: - "Build custom firmware with third-party plugins: BBS's, custom hardware, games, and more. An open ecosystem growing to hundreds of plugins.", - - extends: [vikeReact], - prerender: { - partial: true, - }, - ssr: false, -} satisfies Config diff --git a/pages/+onPageTransitionEnd.ts b/pages/+onPageTransitionEnd.ts deleted file mode 100644 index ce5cff6..0000000 --- a/pages/+onPageTransitionEnd.ts +++ /dev/null @@ -1,4 +0,0 @@ -export async function onPageTransitionEnd() { - console.log("Page transition end") - document.body.classList.remove("page-transition") -} diff --git a/pages/+onPageTransitionStart.ts b/pages/+onPageTransitionStart.ts deleted file mode 100644 index 393afe6..0000000 --- a/pages/+onPageTransitionStart.ts +++ /dev/null @@ -1,9 +0,0 @@ -// https://vike.dev/onPageTransitionStart - -import type { PageContextClient } from "vike/types" - -export async function onPageTransitionStart(pageContext: Partial) { - console.log("Page transition start") - console.log("pageContext.isBackwardNavigation", pageContext.isBackwardNavigation) - document.body.classList.add("page-transition") -} diff --git a/pages/Layout.css b/pages/Layout.css deleted file mode 100644 index 113dfbb..0000000 --- a/pages/Layout.css +++ /dev/null @@ -1,29 +0,0 @@ -/* Links */ -a { - text-decoration: none; -} -#sidebar a { - padding: 2px 10px; - margin-left: -10px; -} -#sidebar a.is-active { - background-color: #eee; -} - -/* Reset */ -body { - margin: 0; - font-family: sans-serif; -} -* { - box-sizing: border-box; -} - -/* Page Transition Animation */ -#page-content { - opacity: 1; - transition: opacity 0.3s ease-in-out; -} -body.page-transition #page-content { - opacity: 0; -} diff --git a/pages/_error/+Page.tsx b/pages/_error/+Page.tsx deleted file mode 100644 index 5fe4d91..0000000 --- a/pages/_error/+Page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { usePageContext } from "vike-react/usePageContext" - -export default function Page() { - const { is404 } = usePageContext() - if (is404) { - return ( - <> -

Page Not Found

-

This page could not be found.

- - ) - } - return ( - <> -

Internal Error

-

Something went wrong.

- - ) -} diff --git a/pages/admin/+Page.tsx b/pages/admin/+Page.tsx deleted file mode 100644 index 9ae56cf..0000000 --- a/pages/admin/+Page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { BuildProgress } from "@/components/BuildProgress" -import { Button } from "@/components/ui/button" -import { api } from "@/convex/_generated/api" -import type { Id } from "@/convex/_generated/dataModel" -import { useMutation, useQuery } from "convex/react" -import { useEffect, useState } from "react" -import { toast } from "sonner" -import { navigate } from "vike/client/router" - -type FilterType = "all" | "failed" - -const FILTER_STORAGE_KEY = "admin-build-filter" - -export default function Admin() { - const [filter, setFilter] = useState(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem(FILTER_STORAGE_KEY) - if (saved === "all" || saved === "failed") { - return saved - } - } - return "failed" - }) - - useEffect(() => { - localStorage.setItem(FILTER_STORAGE_KEY, filter) - }, [filter]) - const isAdmin = useQuery(api.admin.isAdmin) - const failedBuilds = useQuery(api.admin.listFailedBuilds) - const allBuilds = useQuery(api.admin.listAllBuilds) - const retryBuild = useMutation(api.admin.retryBuild) - - const builds = filter === "failed" ? failedBuilds : allBuilds - - // Show loading state - if (isAdmin === undefined) { - return ( -
-
Loading...
-
- ) - } - - // Redirect if not admin - if (isAdmin === false) { - return ( -
-
-

Access Denied

-

You must be an admin to access this page.

- -
-
- ) - } - - const handleRetry = async (buildId: Id<"builds">) => { - try { - await retryBuild({ buildId }) - toast.success("Build retry initiated", { - description: "The build has been queued with the latest YAML.", - }) - } catch (error) { - toast.error("Failed to retry build", { - description: String(error), - }) - throw error - } - } - - return ( -
-
-

Admin - Builds

-

- View and manage builds. Retry failed builds with the latest GitHub Actions workflow YAML. -

-
- - -
-
- -
- {builds === undefined ? ( -
Loading builds...
- ) : builds.length === 0 ? ( -
No {filter === "failed" ? "failed " : ""}builds found.
- ) : ( -
- {builds.map(build => ( -
- -
- ))} -
- )} -
-
- ) -} diff --git a/pages/builds/+Page.tsx b/pages/builds/+Page.tsx deleted file mode 100644 index 71d3461..0000000 --- a/pages/builds/+Page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import Builder from "@/components/Builder" -import { BuildProgress } from "@/components/BuildProgress" -import { GiscusComments } from "@/components/GiscusComments" -import { api } from "@/convex/_generated/api" -import { useMutation, useQuery } from "convex/react" -import { Loader2 } from "lucide-react" -import { toast } from "sonner" -import { usePageContext } from "vike-react/usePageContext" - -export default function BuildsPage() { - const pageContext = usePageContext() - const urlSearchParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null - const cloneHash = urlSearchParams?.get("clone") - const buildHash = urlSearchParams?.get("hash") - const pluginParam = urlSearchParams?.get("plugin") - - // If we have a build hash, show the build progress page - if (buildHash) { - return - } - - // Otherwise, show the builder (handles clone and plugin params) - return -} - -function BuildViewPage({ buildHash }: { buildHash: string }) { - const build = useQuery(api.builds.getByHash, { buildHash }) - const isAdmin = useQuery(api.admin.isAdmin) - const retryBuild = useMutation(api.admin.retryBuild) - - if (build === undefined) { - return ( -
- -
- ) - } - - if (!build) { - return ( -
-
-
-

- No build found for hash {buildHash} -

-
-
-
- ) - } - - const handleRetry = async (buildId: typeof build._id) => { - try { - await retryBuild({ buildId }) - toast.success("Build retry initiated", { - description: "The build has been queued with the latest YAML.", - }) - } catch (error) { - toast.error("Failed to retry build", { - description: String(error), - }) - throw error - } - } - - return ( -
-
- - -
-
- ) -} diff --git a/pages/docs/+Layout.tsx b/pages/docs/+Layout.tsx deleted file mode 100644 index 063111b..0000000 --- a/pages/docs/+Layout.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Link } from "@/components/Link" -import { usePageContext } from "vike-react/usePageContext" - -const navSections = [ - { - items: [{ href: "/docs", label: "Overview" }], - }, - { - heading: "Plugins", - items: [ - { href: "/docs/registry", label: "Overview" }, - { href: "/docs/plugin-authoring", label: "Authoring Guide" }, - ], - }, - { - heading: "Flashing", - items: [ - { href: "/docs/esp32", label: "ESP32" }, - { href: "/docs/nRF52", label: "nRF52" }, - ], - }, -] - -function NavLink({ href, label }: { href: string; label: string }) { - const pageContext = usePageContext() - const { urlPathname } = pageContext - const isActive = href === "/docs" ? urlPathname === href : urlPathname.startsWith(href) - - return ( - - - {label} - - - ) -} - -export default function Layout({ children }: { children: React.ReactNode }) { - return ( -
- -
-
{children}
-
-
- ) -} diff --git a/pages/docs/+Page.mdx b/pages/docs/+Page.mdx deleted file mode 100644 index 6791ee5..0000000 --- a/pages/docs/+Page.mdx +++ /dev/null @@ -1,31 +0,0 @@ -# Mesh Forge - -Mesh Forge is a cloud-based firmware builder that gives you complete control over your Meshtastic node. Choose exactly which modules you want, include third-party plugins, and build your custom firmware in the cloud—no local environment setup required. - -## What is Mesh Forge? - -Mesh Forge removes the hours of painful setup (installing Python, PlatformIO, toolchains, and debugging paths) and guarantees a successful, reproducible build instantly. Simply configure your firmware, click build, and download a ready-to-flash binary—along with the complete source code used to create it. - -**Key Features:** - -- ✅ **No Installation Required** - Build firmware directly in your browser -- ✅ **Complete Control** - Choose exactly which modules and plugins to include -- ✅ **Shareable Builds** - Share custom configurations with a simple link -- ✅ **Source Code Included** - Every build includes the complete source code for audit and modification -- ✅ **Plugin Ecosystem** - Extend firmware functionality without modifying core code - -## The Plugin Ecosystem - -Along with the builder, Mesh Forge includes a plugin ecosystem that allows developers to create extensions to Meshtastic's core firmware without sending Pull Requests to the core repository or forcing users to compile firmware manually. - -The vision is that there may someday be hundreds of plugins covering functionality either too specific or too high level to be considered core. This enables the community to extend Meshtastic in ways that wouldn't be practical to include in the main firmware. - -## Sovereignty - -Every firmware build from Mesh Forge includes not just the binary, but a complete source code zip file containing the configuration files and build recipe (`platformio.ini`) used to create your binary. You are free to audit, modify, and build that code on any system you choose. - -While the build tools themselves (PlatformIO, pip, etc.) still require internet connectivity, Mesh Forge provides full transparency and control over the firmware source code itself. - -## Getting Started - -Ready to build your custom firmware? Visit the [builder](https://meshforge.org/builds) to get started, or explore the [Plugin Registry](/docs/registry) to see what's available. diff --git a/pages/docs/esp32/+Page.mdx b/pages/docs/esp32/+Page.mdx deleted file mode 100644 index e97a5c5..0000000 --- a/pages/docs/esp32/+Page.mdx +++ /dev/null @@ -1,66 +0,0 @@ -# ESP Device Flashing Guide - -## Overview - -This guide will walk you through flashing custom firmware to your ESP device using the firmware files downloaded from MeshForge. - -## Prerequisites - -- Custom firmware built and downloaded from MeshForge -- ESP device (ESP32, ESP8266, etc.) -- USB cable to connect your device to your computer - -## Step-by-Step Instructions - -### 1. Build and Download Firmware - -1. Build your custom firmware for your device on [meshforge.org](https://meshforge.org/) -2. When the build completes, download the compressed firmware archive - -### 2. Extract Firmware Files - -Extract the downloaded archive to a folder. You should see the following files: - -- `firmware.bin` - Main application binary -- `firmware.factory.bin` - Factory application binary -- `firmware.elf` - ELF debug file (not needed for flashing) -- `partitions.bin` - Partition table binary -- `bootloader.bin` - Bootloader binary - -### 3. Flash Using ESPTool - -1. Open [ESP Tool Web Flasher](https://esptool.spacehuhn.com/) in your browser -2. Connect your ESP device to your computer via USB -3. Select the appropriate COM port (or device) when prompted -4. Configure the flash settings using the following memory offsets: - -#### Memory Offsets - -| Offset | File | Description | -| --------- | ---------------------- | --------------------------------------------------------------------- | -| `0x1000` | `bootloader.bin` | Second-stage bootloader binary | -| `0x8000` | `partitions.bin` | Partition table that defines data and application partition locations | -| `0x10000` | `firmware.factory.bin` | Main application (factory) binary | -| `0xe000` | `boot_app0.bin` | Boot application file (if included in your firmware package) | - -**Note:** Some configurations may include additional data partitions like NVS (Non-Volatile Storage) or Wi-Fi calibration data at their defined offsets. Only flash files that are present in your extracted firmware folder. - -### 4. Complete the Flash Process - -1. Click the flash/upload button in ESP Tool -2. Wait for the flashing process to complete -3. You should see a success message when finished - -### 5. Reboot Your Device - -1. Disconnect and reconnect your ESP device, or -2. Press the reset button on your device, or -3. Use the reset command in ESP Tool if available - -Your device should now be running the custom firmware! - -## Troubleshooting - -- **Device not detected:** Make sure you have the correct USB drivers installed for your ESP device -- **Flash failed:** Verify that you're using the correct memory offsets for your device model -- **Device won't boot:** Ensure all required files (bootloader, partitions, and firmware) were flashed successfully diff --git a/pages/docs/nRF52/+Page.mdx b/pages/docs/nRF52/+Page.mdx deleted file mode 100644 index fc6fe47..0000000 --- a/pages/docs/nRF52/+Page.mdx +++ /dev/null @@ -1,106 +0,0 @@ -# nRF52 Device Flashing Guide - -## Overview - -This guide will walk you through flashing custom firmware to your nRF device (nRF52840, nRF52832, etc.) using the UF2 firmware files downloaded from MeshForge. - -## Prerequisites - -- Custom firmware built and downloaded from MeshForge -- nRF device (nRF52840, nRF52832, or compatible) -- USB cable to connect your device to your computer - -## Step-by-Step Instructions - -### 1. Build and Download Firmware - -1. Build your custom firmware for your nRF device on [meshforge.org](https://meshforge.org/) -2. When the build completes, download the compressed firmware archive (`.tgz` file) - -### 2. Extract Firmware Files - -Extract the downloaded `.tgz` archive to a folder. You should see the following files: - -- `firmware.uf2` - UF2 firmware file (this is what you'll flash) -- `firmware.elf` - ELF debug file (not needed for flashing) - -**Note:** The `firmware.uf2` file is the main file you need for flashing. - -### 3. Enter DFU Mode - -Your nRF device needs to be in DFU (Device Firmware Update) mode before flashing. The method varies by device: - -#### Common Methods: - -- **Double-tap reset button**: Quickly press the reset button twice in succession -- **Button combination**: Hold the BOOT/DFU button while pressing and releasing RESET -- **Specific device instructions**: Some devices have unique methods (check your device documentation) - -#### Verify DFU Mode: - -When successfully in DFU mode, you should see: - -- A new USB drive appear on your computer (typically named "DFU" or similar) -- The device LED may change behavior (blinking pattern or solid color) -- The device will not appear as a serial port - -### 4. Flash the Firmware - -1. **Locate the USB drive**: Open your file manager and find the DFU drive that appeared when you entered DFU mode -2. **Copy the UF2 file**: Drag and drop (or copy) the `firmware.uf2` file onto the DFU drive -3. **Wait for completion**: The device will automatically begin flashing. You may see the drive activity indicator or LED changes -4. **Automatic reboot**: Once flashing completes, the device will automatically reboot and exit DFU mode - -### 5. Verify the Flash - -After the device reboots: - -- The DFU drive should disappear from your computer -- The device should appear as a serial port (if applicable) -- Your device should now be running the custom firmware - -## Troubleshooting - -### Device Not Entering DFU Mode - -- **Try different button combinations**: Some devices require specific timing or button sequences -- **Check device documentation**: Your specific device may have unique DFU entry requirements -- **Ensure USB connection**: Make sure the USB cable supports data transfer (not just charging) - -### DFU Drive Not Appearing - -- **Check device manager**: On Windows, verify the device is recognized -- **Try different USB port**: Some USB ports may not work properly -- **Install drivers**: Some devices require specific USB drivers (check manufacturer documentation) -- **Try different cable**: Faulty cables can prevent proper communication - -### Flash Fails or Device Won't Boot - -- **Verify firmware compatibility**: Ensure the firmware matches your exact device model -- **Try re-entering DFU mode**: Sometimes the device needs to be reset and DFU mode re-entered -- **Check file integrity**: Re-download the firmware file to ensure it wasn't corrupted -- **Use erase option**: Some devices may need to be erased before flashing (check device-specific instructions) - -### Device Stuck in DFU Mode - -- **Disconnect and reconnect**: Unplug the USB cable and plug it back in -- **Hard reset**: Some devices have a hard reset method (check device documentation) -- **Re-flash bootloader**: In extreme cases, the bootloader may need to be reflashed using a debugger - -## Additional Notes - -- **Firmware versions**: Always ensure you're flashing firmware compatible with your device model -- **Backup**: If possible, keep a backup of your working firmware before flashing new versions -- **Multiple devices**: If flashing multiple devices, ensure each is in DFU mode before copying the UF2 file - -## Device-Specific Information - -Different nRF devices may have slight variations in the flashing process. Common nRF devices include: - -- **nRF52840**: Most common, supports UF2 bootloader -- **nRF52832**: Older generation, may require different methods -- **Adafruit nRF52840 Feather**: Uses double-tap reset for DFU mode -- **Seeed XIAO nRF52840**: Typically uses double-tap reset -- **RAK4631**: May use different button combinations - -Check your specific device documentation for exact DFU entry procedures. diff --git a/pages/docs/plugin-authoring/+Page.mdx b/pages/docs/plugin-authoring/+Page.mdx deleted file mode 100644 index fe3e44f..0000000 --- a/pages/docs/plugin-authoring/+Page.mdx +++ /dev/null @@ -1,161 +0,0 @@ -# Meshtastic Plugin Authoring Guide - -## Installation & Setup - -> **Note**: Until the plugin system is officially accepted, you must use `pip install` followed by `mpm init` in the firmware folder to apply the plugin patches to the firmware. You'll need to do this to older versions of the firmware even if this is eventually accepted into core. - -```bash -# Install MPM -pip install mesh-plugin-manager - -# From the firmware folder (directory containing platformio.ini) -mpm init -``` - -The build system automatically uses MPM during PlatformIO builds to include all plugins and generate protobuf bindings. - -## Creating a New Plugin - -The easiest way to start a new plugin is using the `mpm new` command: - -```bash -# From any directory -mpm new "my-module-slug" - -# Use --force to overwrite an existing plugin -mpm new "my-module-slug" --force -``` - -This creates a complete plugin template: - -- If a `plugins` directory exists in the current working directory, the plugin is created in `plugins/my-module-slug/src/` -- Otherwise, the plugin is created in `my-module-slug/src/` in the current directory - -The template includes: - -- `plugin.h` - Module registration with version macros -- `Module.h` - Module header file inheriting from `SinglePortModule` -- `Module.cpp` - Module implementation with basic structure - -The template includes: - -- Proper module registration with `#pragma MPM_MODULE` -- Variable assignment for module instance -- LOG_INFO initialization message -- Empty message handler ready for implementation - -After creating your plugin, edit the generated files to implement your functionality, then run `mpm generate` to regenerate protobuf files and module initialization code. - -## Plugin Structure - -The only requirement for a plugin is that it must have a `./src` directory: - -``` -plugins/ -└── myplugin/ - └── src/ - ├── MyModule.h - ├── MyModule.cpp - └── mymodule.proto -``` - -- Plugin directory name can be anything -- All source files must be placed in `./src` -- Only files in `./src` are compiled (the root plugin directory and all other subdirectories are excluded from the build) - -## Automatic Protobuf Generation - -MPM automatically scans for and generates protobuf files: - -- **Discovery**: Recursively scans plugin directories for `.proto` files -- **Options file**: Auto-detects matching `.options` files (e.g., `mymodule.proto` → `mymodule.options`) -- **Generation**: Uses `nanopb` tooling to generate C++ files -- **Output**: Generated files are placed in the same directory as the `.proto` file -- **Timing**: Runs during PlatformIO pre-build phase (configured in `platformio.ini`) - -Example protobuf structure: - -``` -src/plugins/myplugin/src/ -├── mymodule.proto # Protobuf definition -├── mymodule.options # Nanopb options (optional) -├── mymodule.pb.h # Generated header -└── mymodule.pb.c # Generated implementation -``` - -## Include Path Setup - -The plugin's `src/` directory is automatically added to the compiler's include path (`CPPPATH`) during build: - -- Headers in `src/` can be included directly: `#include "MyModule.h"` -- No need to specify relative paths from other plugin files -- The build system handles this automatically - -## Module Registration - -If your plugin implements a Meshtastic module, use the `#pragma MPM_MODULE` directive in your header file: - -1. Add `#pragma MPM_MODULE(ClassName)` to your module's header file (`.h`) -2. Optionally specify a variable name: `#pragma MPM_MODULE(ClassName, variableName)` -3. Optionally specify dependencies: `#pragma MPM_MODULE(ClassName, variableName, ['dep1', 'dep2'])` -4. If you specify a variable name, declare it as `extern` in your header file -5. Your module will be automatically initialized when the firmware starts - -Example (without variable): - -```cpp -// MyModule.h -#pragma once -#pragma MPM_MODULE(MyModule) - -class MyModule : public SinglePortModule { - // ... module definition ... -}; -``` - -Example (with variable - for modules that need to be referenced elsewhere): - -```cpp -// MyModule.h -#pragma once -#pragma MPM_MODULE(MyModule, myModule) - -#include "SinglePortModule.h" - -class MyModule : public SinglePortModule { - // ... module definition ... -}; - -// Declare the variable as extern so other files can reference it -extern MyModule *myModule; -``` - -The variable will be assigned in the generated `init_dynamic_modules()` function. If you don't need to reference your module from other files, you can omit the variable name and extern declaration. - -### Plugin Dependencies - -Plugin dependencies are automatically read from `meshtastic-lock.json` (which is generated from registry dependencies). MPM ensures that dependency plugins are initialized before dependent plugins using topological sorting. - -Dependencies are specified in your plugin's `meshtastic.json` manifest file: - -```json -{ - "name": "my-plugin", - "dependencies": { - "lobbs": ">=1.1.0", - "lodb": ">=1.0.0" - } -} -``` - -When you install your plugin with `mpm install`, these dependencies are resolved and stored in `meshtastic-lock.json`. During code generation, MPM reads these dependencies and ensures proper initialization order. Circular dependencies will generate a warning during code generation. - -> **Note**: Module registration is optional. Plugins that don't implement Meshtastic modules (e.g., utility libraries) don't need this. - -For details on writing Meshtastic modules, see the [Module API documentation](https://meshtastic.org/docs/development/device/module-api/). - -## Example Plugins - -- [LoBBS](https://github.com/MeshEnvy/lobbs) - an on-firmware BBS -- [LoDB](https://github.com/MeshEnvy/lodb) - a microncontroller-friendly relational database for persisting settings, data, and more -- See https://meshforge.org for more diff --git a/pages/docs/registry/+Page.mdx b/pages/docs/registry/+Page.mdx deleted file mode 100644 index 1d039a3..0000000 --- a/pages/docs/registry/+Page.mdx +++ /dev/null @@ -1,56 +0,0 @@ -# Mesh Plugin Registry - -The Mesh Plugin Registry is a collection of community-developed plugins that extend Meshtastic firmware functionality. This guide explains how to discover, use, and build firmware with plugins from the registry. - -## What is the Registry? - -The registry contains plugins that add new features and capabilities to Meshtastic devices. Each plugin is maintained by the community and can be easily integrated into custom firmware builds. - -## How to Use Plugins - -### Using Mesh Forge (Recommended) - -The easiest way to use plugins is through [Mesh Forge](https://meshforge.org/builds), which lets you build custom firmware with plugins directly in your browser: - -1. Visit [meshforge.org/builds](https://meshforge.org/builds) -2. Browse available plugins from the registry -3. Select the plugins you want to include -4. Build your custom firmware -5. Download and flash the firmware to your device - -**Benefits:** - -- Zero installation required -- Simple web interface -- Automatic dependency resolution -- Ready-to-flash firmware files - -### Using Mesh Plugin Manager (Advanced) - -For local development and more control, you can use the [Mesh Plugin Manager](https://pypi.org/project/mesh-plugin-manager/) command-line tool: - -1. Install Mesh Plugin Manager: `pip install mesh-plugin-manager` -2. Set up PlatformIO and Poetry (required dependencies) -3. Use the CLI to build firmware with selected plugins -4. Flash the firmware to your device - -**Benefits:** - -- Full control over the build process -- Local development workflow -- CLI interface for automation -- Developer-friendly - -## Creating Your Own Plugin - -If you want to create and contribute plugins to the registry, check out the [Plugin Development Guide](https://github.com/MeshEnvy/firmware/blob/meshenvy/module-registry/plugins/README.md) for documentation on: - -- Plugin structure and architecture -- Protobuf message generation -- Module registration -- Testing and submission guidelines - -## Additional Resources - -- [MeshEnvy](https://meshenvy.org) - Built by MeshEnvy (not affiliated with Meshtastic) -- [Meshtastic](https://meshtastic.org) - Learn more about Meshtastic diff --git a/pages/index/+Page.tsx b/pages/index/+Page.tsx deleted file mode 100644 index 6c160f6..0000000 --- a/pages/index/+Page.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import { DiscordButton } from "@/components/DiscordButton" -import { PluginCard } from "@/components/PluginCard" -import { RedditButton } from "@/components/RedditButton" -import { Button } from "@/components/ui/button" -import { api } from "@/convex/_generated/api" -import registryData from "@/public/registry.json" -import { useQuery } from "convex/react" -import { useEffect, useState } from "react" -import { navigate } from "vike/client/router" - -function getGitHubOwnerRepo(repoUrl?: string): { owner: string; repo: string } | null { - if (!repoUrl) return null - try { - const url = new URL(repoUrl) - if (url.hostname === "github.com" || url.hostname === "www.github.com") { - const pathParts = url.pathname.split("/").filter(Boolean) - if (pathParts.length >= 2) { - return { owner: pathParts[0], repo: pathParts[1] } - } - } - } catch { - // Invalid URL - } - return null -} - -function QuickBuildIcon(props: React.SVGProps) { - return ( - - Custom build - - - ) -} - -function DocsIcon(props: React.SVGProps) { - return ( - - Docs - - - ) -} - -export default function LandingPage() { - const flashCounts = useQuery(api.plugins.getAll) - const [githubStars, setGithubStars] = useState>({}) - - const featuredPlugins = Object.entries(registryData) - .filter(([, plugin]) => plugin.featured === true) - .sort(([, pluginA], [, pluginB]) => pluginA.name.localeCompare(pluginB.name)) - - useEffect(() => { - // Fetch GitHub stars for featured plugins - const fetchStars = async () => { - const stars: Record = {} - const promises = featuredPlugins.map(async ([slug, plugin]) => { - const ownerRepo = getGitHubOwnerRepo(plugin.repo) - if (!ownerRepo) return - - try { - const res = await fetch(`https://api.github.com/repos/${ownerRepo.owner}/${ownerRepo.repo}`) - const data = await res.json() - if (data.stargazers_count !== undefined) { - stars[slug] = data.stargazers_count - } - } catch { - // Silently fail if GitHub API is unavailable - } - }) - - await Promise.all(promises) - setGithubStars(stars) - } - - if (featuredPlugins.length > 0) { - fetchStars() - } - }, [featuredPlugins.length]) - - const customBuildPlugin = { - id: "custom-build", - name: "Build your own", - description: "Create a custom firmware build with your choice of plugins and modules", - imageUrl: "/custom-build.webp", - featured: false, - } - - return ( -
-
-
-

- - Mesh Apps - -

-

- Mesh Forge is an open ecosystem of apps, BBS's, custom hardware, games, and more. -

- - {featuredPlugins.length > 0 && ( -
-
-

Popular Builds

-
- {featuredPlugins.map(([slug, plugin]) => ( -
- -
- ))} -
- -
-
-
-
- )} - -
-
- - - -
-
- - {/* Benefits Grid */} -
-
-

Zero Install

-

No downloads, no toolchains. Everything runs in your browser.

-
-
-

Custom Firmware

-

Build bespoke Meshtastic firmware tailored to your exact needs.

-
-
-

Community Extensions

-

Include community modules and extensions beyond core Meshtastic.

-
-
-

Share & Remix

-

Publish your build profiles and let others remix your configs.

-
-
-

Cloud Builds

-

- Compile in the cloud, flash directly to your device—no local setup required. -

-
-
-
-
-
- ) -} diff --git a/pages/license/+Layout.tsx b/pages/license/+Layout.tsx deleted file mode 100644 index 686e669..0000000 --- a/pages/license/+Layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function Layout({ children }: { children: React.ReactNode }) { - return ( -
-
{children}
-
- ) -} diff --git a/pages/license/+Page.mdx b/pages/license/+Page.mdx deleted file mode 100644 index e45a83d..0000000 --- a/pages/license/+Page.mdx +++ /dev/null @@ -1,36 +0,0 @@ -# MeshForge Licensing Notice - -## 1. Generated Projects (the zip files you download) - -Every project you download from MeshForge is a modified version of one or more upstream open-source code bases. The primary codebase is licensed under the GNU General Public License version 3 (GPLv3). - -Because the GPLv3 is "viral", the entire combined work you receive — including the original GPLv3 code plus all patches, plugins, themes, or extensions added by MeshForge or selected by you — is licensed **exclusively** under the GNU GPLv3. - -- You may use, modify, and redistribute the downloaded project **only** under the terms of the GPLv3 ([full text](https://www.gnu.org/licenses/gpl-3.0.html)). -- No additional permissions or dual-licensing terms are granted for the combined work. -- All original copyright and license notices are preserved inside the zip. - -## 2. Plugins, patches, and extensions from the MeshForge registry - -Many optional components available in our registry are originally licensed under MIT, Apache 2.0, BSD, ISC, or other licenses that are explicitly compatible with GPLv3. - -We make every reasonable effort to ensure that only GPLv3-compatible components are offered in the registry. However, the final responsibility for verifying that every selected component is compatible with GPLv3 lies with you, the user. - -Users who receive the combined project are always free to extract individual components that were originally under a GPLv3-compatible permissive license (MIT, Apache 2.0, BSD, etc.) and reuse those components under their original permissive license terms, provided they comply with those original licenses. - -## 3. MeshForge website and generation tool - -The MeshForge website and the software that downloads upstream code, applies customisations, combines selected plugins, and produces the final zip file are separate works that do not incorporate any GPLv3-covered code. - -MeshForge.org is owned and operated by MeshEnvy NCC, a Nevada 501(c)(3) Nonprofit Corporation. - -Copyright © 2025 MeshForge.org -Licensed under the MIT License ([full text](https://opensource.org/licenses/MIT)) - -## Summary - -- Downloaded projects (the complete zip) → GPLv3 only -- Individual compatible components inside the zip → may still be reused under their original permissive license if extracted -- MeshForge tool and website → MIT License - -Questions or concerns? → [legal@meshforge.org](mailto:legal@meshforge.org) diff --git a/pages/plugins/+Page.tsx b/pages/plugins/+Page.tsx deleted file mode 100644 index a7a3b23..0000000 --- a/pages/plugins/+Page.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { PluginCard } from "@/components/PluginCard" -import { api } from "@/convex/_generated/api" -import registryData from "@/public/registry.json" -import { PluginDisplay } from "@/types" -import { useQuery } from "convex/react" -import { useEffect, useState } from "react" - -function getGitHubOwnerRepo(repoUrl?: string): { owner: string; repo: string } | null { - if (!repoUrl) return null - try { - const url = new URL(repoUrl) - if (url.hostname === "github.com" || url.hostname === "www.github.com") { - const pathParts = url.pathname.split("/").filter(Boolean) - if (pathParts.length >= 2) { - return { owner: pathParts[0], repo: pathParts[1] } - } - } - } catch { - // Invalid URL - } - return null -} - -export default function PluginsPage() { - const flashCounts = useQuery(api.plugins.getAll) - const [githubStars, setGithubStars] = useState>({}) - - const plugins = Object.entries(registryData).sort(([, pluginA], [, pluginB]) => { - // Featured plugins first - const featuredA = pluginA.featured ?? false - const featuredB = pluginB.featured ?? false - if (featuredA !== featuredB) { - return featuredA ? -1 : 1 - } - // Then alphabetical by name - return pluginA.name.localeCompare(pluginB.name) - }) - - useEffect(() => { - // Fetch GitHub stars for all plugins - const fetchStars = async () => { - const stars: Record = {} - const promises = plugins.map(async ([slug, plugin]) => { - const ownerRepo = getGitHubOwnerRepo(plugin.repo) - if (!ownerRepo) return - - try { - const res = await fetch(`https://api.github.com/repos/${ownerRepo.owner}/${ownerRepo.repo}`) - const data = await res.json() - if (data.stargazers_count !== undefined) { - stars[slug] = data.stargazers_count - } - } catch { - // Silently fail if GitHub API is unavailable - } - }) - - await Promise.all(promises) - setGithubStars(stars) - } - - fetchStars() - }, []) - - return ( -
-
-
-

Plugin Registry

-

- Browse community-developed plugins that extend Meshtastic firmware functionality. Featured plugins are shown - first. -

-
- -
- {plugins.map(([slug, plugin]) => { - const pluginDisplay = plugin as PluginDisplay - return ( - - ) - })} -
-
-
- ) -} diff --git a/pages/plugins/@slug/+Page.tsx b/pages/plugins/@slug/+Page.tsx deleted file mode 100644 index 6ae5f05..0000000 --- a/pages/plugins/@slug/+Page.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { Button } from "@/components/ui/button" -import { api } from "@/convex/_generated/api" -import registryData from "@/public/registry.json" -import { PluginDisplay } from "@/types" -import { useQuery } from "convex/react" -import { Download, Github, Home, Star } from "lucide-react" -import { usePageContext } from "vike-react/usePageContext" -import { navigate } from "vike/client/router" - -function getGitHubStarsBadgeUrl(repoUrl?: string): string | null { - if (!repoUrl) return null - try { - const url = new URL(repoUrl) - if (url.hostname === "github.com" || url.hostname === "www.github.com") { - const pathParts = url.pathname.split("/").filter(Boolean) - if (pathParts.length >= 2) { - const owner = pathParts[0] - const repo = pathParts[1] - return `https://img.shields.io/github/stars/${owner}/${repo}?style=flat&logo=github&logoColor=white&labelColor=rgb(0,0,0,0)&color=rgb(30,30,30)&label=★` - } - } - } catch { - // Invalid URL - } - return null -} - -export default function PluginPage() { - const pageContext = usePageContext() - const slug = pageContext.routeParams?.slug as string | undefined - const pluginStats = useQuery(api.plugins.get, slug ? { slug } : "skip") - - if (!slug) { - return ( -
-
-

Plugin slug missing.

-
-
- ) - } - - const plugin = (registryData as Record)[slug] - const starsBadgeUrl = getGitHubStarsBadgeUrl(plugin?.repo) - - if (!plugin) { - return ( -
-
-
-

- Plugin {slug} not found. -

-
-
-
- ) - } - - return ( -
-
-
-
- {plugin.imageUrl && ( - {`${plugin.name} - )} -
-
-

{plugin.name}

- {plugin.featured && ( - - - Featured - - )} -
-

{plugin.description}

-
- {plugin.version && ( -
- Version: - v{plugin.version} -
- )} - {plugin.author && ( -
- Author: - {plugin.author} -
- )} -
- - {(pluginStats?.flashCount ?? 0).toLocaleString()} -
- {starsBadgeUrl && plugin.repo && ( - - GitHub stars - - )} -
-
-
- -
- {plugin.repo && ( - - )} - {plugin.homepage && plugin.homepage !== plugin.repo && ( - - )} - -
- - {plugin.dependencies && Object.keys(plugin.dependencies).length > 0 && ( -
-

Dependencies

-
- {Object.entries(plugin.dependencies).map(([depName, depVersion]) => ( -
- {depName}: - {depVersion} -
- ))} -
-
- )} - - {plugin.includes && plugin.includes.length > 0 && ( -
-

Supported Platforms

-
- {plugin.includes.map(platform => ( - - {platform} - - ))} -
-
- )} -
-
-
- ) -} diff --git a/pages/privacy/+Layout.tsx b/pages/privacy/+Layout.tsx deleted file mode 100644 index 68e08c0..0000000 --- a/pages/privacy/+Layout.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export default function Layout({ children }: { children: React.ReactNode }) { - return ( -
-
{children}
-
- ) -} - diff --git a/pages/terms/+Layout.tsx b/pages/terms/+Layout.tsx deleted file mode 100644 index 68e08c0..0000000 --- a/pages/terms/+Layout.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export default function Layout({ children }: { children: React.ReactNode }) { - return ( -
-
{children}
-
- ) -} - diff --git a/public/registry.json b/public/registry.json index 258b8cb..0967ef4 100644 --- a/public/registry.json +++ b/public/registry.json @@ -1,30 +1 @@ -{ - "lodb": { - "name": "LoDB", - "description": "Micro database for Meshtastic - A synchronous, protobuf-based database for Meshtastic", - "repo": "https://github.com/MeshEnvy/lodb", - "homepage": "https://github.com/MeshEnvy/lodb", - "imageUrl": "https://raw.githubusercontent.com/MeshEnvy/lodb/refs/heads/main/logo.webp", - "version": "1.2.0", - "author": "benallfree", - "featured": false, - "dependencies": { - "meshtastic": ">=2.7.0" - } - }, - "lobbs": { - "name": "LoBBS", - "author": "benallfree", - "description": "BBS for Meshtastic right on the firmware - A full bulletin board system that runs entirely inside the Meshtastic firmware", - "imageUrl": "https://raw.githubusercontent.com/MeshEnvy/lobbs/refs/heads/main/logo.webp", - "repo": "https://github.com/MeshEnvy/lobbs", - "homepage": "https://github.com/MeshEnvy/lobbs", - "version": "1.2.1", - "featured": true, - "includes": ["esp32"], - "dependencies": { - "lodb": ">=1.2.0", - "meshtastic": ">=2.7.0" - } - } -} +{} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..5392bfc --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,42 @@ +import Footer from "@/components/Footer" +import Navbar from "@/components/Navbar" +import { Navigate, Route, Routes, useLocation } from "react-router-dom" +import AdminPage from "./pages/AdminPage" +import HomePage from "./pages/HomePage" +import LegalLicensePage from "./pages/LegalLicensePage" +import LegalPrivacyPage from "./pages/LegalPrivacyPage" +import LegalTermsPage from "./pages/LegalTermsPage" +import NotFoundPage from "./pages/NotFoundPage" +import RepoPage from "./pages/RepoPage" + +function Layout({ children }: { children: React.ReactNode }) { + const loc = useLocation() + const hideNav = loc.pathname === "/" + return ( +
+ {!hideNav && } +
{children}
+
+
+ ) +} + +export default function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/src/components/AutocompleteField.tsx b/src/components/AutocompleteField.tsx new file mode 100644 index 0000000..daf77f1 --- /dev/null +++ b/src/components/AutocompleteField.tsx @@ -0,0 +1,76 @@ +import { useId } from 'react' + +type AutocompleteFieldProps = { + label: string + options: readonly string[] + value: string + onChange: (value: string) => void + onBlur?: () => void + disabled?: boolean + id?: string + placeholder?: string + /** Single-row toolbar: label left, input grows. */ + layout?: 'stacked' | 'inline' +} + +/** Native ``-backed field: typeahead from the browser + keyboard friendly. */ +export function AutocompleteField({ + label, + options, + value, + onChange, + onBlur, + disabled, + id, + placeholder = 'Type or pick from list…', + layout = 'stacked', +}: AutocompleteFieldProps) { + const rid = useId().replace(/:/g, '') + const inputId = id ?? `ac-${rid}` + const listId = `${inputId}-options` + + const inputClass = + layout === 'inline' + ? 'h-9 min-w-[7rem] flex-1 bg-slate-900 border border-slate-700 rounded-md px-2.5 text-sm text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-600/50 disabled:cursor-not-allowed disabled:opacity-50' + : 'w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2.5 text-sm text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-cyan-600/50 disabled:cursor-not-allowed disabled:opacity-50' + + return ( + + ) +} diff --git a/src/components/EspFlasher.tsx b/src/components/EspFlasher.tsx new file mode 100644 index 0000000..a66b266 --- /dev/null +++ b/src/components/EspFlasher.tsx @@ -0,0 +1,185 @@ +import { Button } from '@/components/ui/button' +import { buildFlashParts, layoutPreviewFromManifest, manifestFromMap, type FlashManifest } from '../lib/espFlashLayout' +import { pulseUsbBootloaderPort, runEspFlash } from '../lib/espFlashRun' +import { extractTarGz, findInTar } from '../lib/untarGz' +import { useCallback, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' + +type EspFlasherProps = { + bundleUrl: string + /** 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 +} + +export default function EspFlasher({ + bundleUrl, + flashButtonLabel = 'Connect serial & flash', + flashBusyLabel = 'Flashing…', + flashButtonSize = 'default', + className = '', + condensed = false, +}: EspFlasherProps) { + const [busy, setBusy] = useState(false) + const [eraseAll, setEraseAll] = useState(false) + const [noReset, setNoReset] = useState(false) + const [baud, setBaud] = useState(921600) + const [layoutPreview, setLayoutPreview] = useState(null) + const [log, setLog] = useState('') + const logRef = useRef('') + + const terminal = useMemo( + () => ({ + clean: () => { + logRef.current = '' + setLog('') + }, + write: (data: string) => { + logRef.current += data + setLog(logRef.current) + }, + writeLine: (data: string) => { + logRef.current += data + '\n' + setLog(logRef.current) + }, + }), + [] + ) + + const prepareBundle = useCallback(async () => { + const res = await fetch(bundleUrl) + if (!res.ok) throw new Error(`Download failed: ${res.status}`) + const buf = new Uint8Array(await res.arrayBuffer()) + const files = extractTarGz(buf) + const m = manifestFromMap(files) + setLayoutPreview(m) + return files + }, [bundleUrl]) + + const flash = useCallback(async () => { + if (!('serial' in navigator)) { + toast.error('Web Serial is not supported in this browser') + return + } + setBusy(true) + terminal.clean() + try { + const files = await prepareBundle() + const parts = buildFlashParts(files) + if (!parts) { + toast.error('Could not detect flash layout from bundle') + setBusy(false) + return + } + + await runEspFlash({ + parts, + baud, + eraseAll, + terminal, + resetMode: noReset ? 'no_reset' : 'default_reset', + }) + toast.success('Flash complete') + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + terminal.writeLine(`\nError: ${msg}`) + toast.error('Flash failed', { description: msg }) + } finally { + setBusy(false) + } + }, [baud, eraseAll, noReset, prepareBundle, terminal]) + + const boot1200 = useCallback(async () => { + try { + await pulseUsbBootloaderPort() + toast.success('1200 baud pulse sent', { + description: 'If the board did not enter bootloader, hold BOOT, tap RST, then try flash again.', + }) + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)) + } + }, []) + + return ( +
+ {condensed ? null : ( + <> +

ESP flash (Web Serial)

+

+ Uses esptool-js. Connect USB, put the board in bootloader if needed, then flash. Wrong offsets can brick + hardware—verify the map. +

+ + )} + {condensed ? ( +

+ USB + Chromium Web Serial. Verify the flash map before writing—wrong images can brick hardware. +

+ ) : null} + + {layoutPreview ? ( +
    + {layoutPreviewFromManifest(layoutPreview).map((line, i) => ( +
  • {line}
  • + ))} +
+ ) : ( +

+ 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. +

+ )} + +
+ + + +
+ +
+ + +
+ + {log ? ( +
+          {log}
+        
+ ) : null} +
+ ) +} diff --git a/src/components/MarkdownDoc.tsx b/src/components/MarkdownDoc.tsx new file mode 100644 index 0000000..f34968a --- /dev/null +++ b/src/components/MarkdownDoc.tsx @@ -0,0 +1,30 @@ +import ReactMarkdown from 'react-markdown' +import { Link } from 'react-router-dom' +import remarkGfm from 'remark-gfm' + +export default function MarkdownDoc({ markdown }: { markdown: string }) { + return ( +
+ { + if (href?.startsWith('/')) + return ( + + {children} + + ) + return ( + + {children} + + ) + }, + }} + > + {markdown} + +
+ ) +} diff --git a/src/content/license.md b/src/content/license.md new file mode 100644 index 0000000..767ad12 --- /dev/null +++ b/src/content/license.md @@ -0,0 +1,27 @@ +# MeshForge Licensing Notice + +## 1. Build artifacts you download + +MeshForge compiles **your** public GitHub repository with PlatformIO and serves the resulting binaries (for example a `.tar.gz` bundle). Those artifacts are derived from **your upstream project** and its dependencies. **You** are responsible for complying with the licenses that apply to that repository (GPLv3, MIT, Apache-2.0, etc.). + +MeshForge does not grant you any license to upstream code beyond what you already have from the repository and its license terms. + +## 2. Third-party code in community firmware repositories + +MeshForge builds **your** GitHub-hosted PlatformIO project as-is. Licensing, compliance, and redistribution of that upstream project are solely between you and the upstream authors. MeshForge does not add a separate “registry” layer of plugins to those builds. + +## 3. MeshForge website and orchestration + +The MeshForge website, Convex backend, and CI integration that fetch archives and run builds are separate from your firmware sources. They are provided under the MIT License unless otherwise noted for a specific file. + +MeshForge.org is owned and operated by MeshEnvy NCC, a Nevada 501(c)(3) Nonprofit Corporation. + +Copyright © 2025 MeshForge.org +Licensed under the MIT License ([full text](https://opensource.org/licenses/MIT)) + +## Summary + +- Artifacts produced from GPLv3 (or other) upstream repos → follow that upstream license +- MeshForge tool and website → MIT License + +Questions or concerns? → [legal@meshforge.org](mailto:legal@meshforge.org) diff --git a/pages/privacy/+Page.mdx b/src/content/privacy.md similarity index 100% rename from pages/privacy/+Page.mdx rename to src/content/privacy.md diff --git a/pages/terms/+Page.mdx b/src/content/terms.md similarity index 100% rename from pages/terms/+Page.mdx rename to src/content/terms.md diff --git a/pages/tailwind.css b/src/index.css similarity index 61% rename from pages/tailwind.css rename to src/index.css index d98956d..cf98657 100644 --- a/pages/tailwind.css +++ b/src/index.css @@ -63,14 +63,6 @@ color: oklch(0.985 0 0); } -.prose.prose-invert h4 { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 0.5rem; - margin-top: 1rem; - color: oklch(0.985 0 0); -} - .prose.prose-invert p { margin-bottom: 1rem; line-height: 1.75; @@ -81,21 +73,6 @@ color: oklch(0.488 0.243 264.376); font-weight: 500; text-decoration: underline; - text-decoration-color: oklch(0.488 0.243 264.376 / 0.3); - text-underline-offset: 2px; - transition: - color 0.2s, - text-decoration-color 0.2s; -} - -.prose.prose-invert a:hover { - color: oklch(0.488 0.243 264.376 / 0.8); - text-decoration-color: oklch(0.488 0.243 264.376 / 0.6); -} - -.prose.prose-invert strong { - font-weight: 600; - color: oklch(0.985 0 0); } .prose.prose-invert code { @@ -103,9 +80,8 @@ padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.875rem; - font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + font-family: ui-monospace, SFMono-Regular, monospace; color: oklch(0.488 0.243 264.376); - border: 1px solid oklch(1 0 0 / 10%); } .prose.prose-invert pre { @@ -115,39 +91,3 @@ padding: 1rem; overflow-x: auto; } - -.prose.prose-invert pre code { - background-color: transparent; - padding: 0; - border: 0; - color: oklch(0.985 0 0); -} - -.prose.prose-invert ul, -.prose.prose-invert ol { - margin-bottom: 1rem; -} - -.prose.prose-invert ul > li, -.prose.prose-invert ol > li { - margin-top: 0.5rem; - color: oklch(0.708 0 0); -} - -.prose.prose-invert ul > li::marker, -.prose.prose-invert ol > li::marker { - color: oklch(0.488 0.243 264.376); -} - -.prose.prose-invert blockquote { - border-left: 4px solid oklch(0.488 0.243 264.376); - padding-left: 1rem; - font-style: italic; - color: oklch(0.708 0 0); - margin: 1rem 0; -} - -.prose.prose-invert hr { - border-color: oklch(1 0 0 / 10%); - margin: 2rem 0; -} diff --git a/src/lib/buildKey.ts b/src/lib/buildKey.ts new file mode 100644 index 0000000..0bd5ad5 --- /dev/null +++ b/src/lib/buildKey.ts @@ -0,0 +1,4 @@ +export function normalizeBuildKey(resolvedSourceSha: string, targetEnv: string): string { + const t = targetEnv.replace(/\//g, '_') + return `${resolvedSourceSha}_${t}` +} diff --git a/src/lib/espFlashLayout.ts b/src/lib/espFlashLayout.ts new file mode 100644 index 0000000..1a4fe41 --- /dev/null +++ b/src/lib/espFlashLayout.ts @@ -0,0 +1,56 @@ +import { findInTar, parseFlashManifest, type FlashManifest } from './untarGz' + +export type FlashPart = { data: Uint8Array; address: number; name: string } + +export function layoutPreviewFromManifest(m: FlashManifest): string[] { + return m.images.map(im => `${im.file} @ ${String(im.offset)}`) +} + +/** Build ordered flash parts from a flat map (tar paths or bare filenames → bytes). */ +export function buildFlashParts(files: Map): FlashPart[] | null { + const manifestRaw = findInTar(files, 'flash-manifest.json') + if (manifestRaw) { + const text = new TextDecoder().decode(manifestRaw) + const m = parseFlashManifest(text) + if (m) { + const out: FlashPart[] = [] + for (const img of m.images) { + const data = findInTar(files, img.file) + if (!data) return null + const addr = typeof img.offset === 'string' ? parseInt(img.offset, 0) : Number(img.offset) + if (!Number.isFinite(addr)) return null + out.push({ data, address: addr, name: img.file }) + } + if (out.length) return out + } + } + + const bootloader = findInTar(files, 'bootloader.bin') + const partitions = findInTar(files, 'partitions.bin') + const bootApp0 = findInTar(files, 'boot_app0.bin') + const factory = findInTar(files, 'firmware.factory.bin') + const firmware = findInTar(files, 'firmware.bin') + + const app = factory ?? firmware + if (bootloader && partitions && app) { + const arr: FlashPart[] = [ + { data: bootloader, address: 0x1000, name: 'bootloader.bin' }, + { data: partitions, address: 0x8000, name: 'partitions.bin' }, + { data: app, address: 0x10000, name: factory ? 'firmware.factory.bin' : 'firmware.bin' }, + ] + if (bootApp0) arr.push({ data: bootApp0, address: 0xe000, name: 'boot_app0.bin' }) + return arr + } + + if (app && !bootloader && !partitions) { + return [{ data: app, address: 0x0, name: factory ? 'firmware.factory.bin' : 'firmware.bin' }] + } + + return null +} + +export function manifestFromMap(files: Map): FlashManifest | null { + const raw = findInTar(files, 'flash-manifest.json') + if (!raw) return null + return parseFlashManifest(new TextDecoder().decode(raw)) +} diff --git a/src/lib/espFlashRun.ts b/src/lib/espFlashRun.ts new file mode 100644 index 0000000..1e90e00 --- /dev/null +++ b/src/lib/espFlashRun.ts @@ -0,0 +1,67 @@ +import { ESPLoader, Transport } from 'esptool-js' +import type { FlashPart } from './espFlashLayout' + +type EspTerminal = { + clean: () => void + write: (data: string) => void + writeLine: (data: string) => void +} + +export async function runEspFlash(options: { + parts: FlashPart[] + baud: number + eraseAll: boolean + terminal: EspTerminal + resetMode?: 'default_reset' | 'no_reset' +}): Promise { + const { parts, baud, eraseAll, terminal, resetMode = 'default_reset' } = options + if (!('serial' in navigator)) { + throw new Error('Web Serial is not available (use Chromium on https:// or localhost)') + } + + const port = await navigator.serial.requestPort() + const transport = new Transport(port) + const loader = new ESPLoader({ + transport, + baudrate: baud, + terminal, + }) + + const fileArray = parts.map(p => ({ data: p.data, address: p.address })) + + await loader.main(resetMode) + const flashSize = await loader.detectFlashSize() + + await loader.writeFlash({ + fileArray, + flashMode: 'dio', + flashFreq: '40m', + flashSize, + eraseAll, + compress: true, + reportProgress: (i, written, total) => { + loader.info(`Image ${i + 1}/${fileArray.length}: ${Math.round((100 * written) / total)}%`) + }, + }) + + await loader.after('hard_reset') + await transport.disconnect() +} + +/** Classic ESP32/S3 USB CDC bootloader entry: open port at 1200 baud briefly. */ +export async function pulseUsbBootloaderPort(): Promise { + if (!('serial' in navigator)) { + throw new Error('Web Serial is not available') + } + const port = await navigator.serial.requestPort() + try { + await port.open({ baudRate: 1200 }) + await new Promise(resolve => setTimeout(resolve, 200)) + } finally { + try { + await port.close() + } catch { + // ignore + } + } +} diff --git a/src/lib/formatBuildErrorSummary.ts b/src/lib/formatBuildErrorSummary.ts new file mode 100644 index 0000000..1961e3e --- /dev/null +++ b/src/lib/formatBuildErrorSummary.ts @@ -0,0 +1,15 @@ +/** Turn noisy Convex / GitHub errors into short copy for the repo build card. */ +export function formatBuildErrorSummary(summary: string | undefined): string { + if (!summary) return '' + if (summary.includes('Unexpected inputs provided')) { + return ( + 'GitHub Actions rejected this workflow dispatch: the workflow file on the Mesh Forge GitHub repo ' + + 'does not declare the same `workflow_dispatch` inputs as this app (update `custom_build.yml` / ' + + '`custom_build_test.yml` on `MeshEnvy/mesh-forge` and try again).' + ) + } + if (summary.length > 600) { + return `${summary.slice(0, 600)}…` + } + return summary +} diff --git a/src/lib/githubHomepage.ts b/src/lib/githubHomepage.ts new file mode 100644 index 0000000..e98d3c3 --- /dev/null +++ b/src/lib/githubHomepage.ts @@ -0,0 +1,22 @@ +/** Normalize GitHub repo `homepage` for href and a short label (GitHub About–style). */ +export function homepageHref(raw: string): string { + const t = raw.trim() + if (!t) return '#' + if (/^https?:\/\//i.test(t)) return t + return `https://${t}` +} + +export function homepageLabel(raw: string): string { + const t = raw.trim() + if (!t) return '' + try { + const u = new URL(homepageHref(t)) + let out = u.hostname.replace(/^www\./i, '') + if (u.pathname !== '/' && u.pathname !== '') { + out += u.pathname.replace(/\/$/, '') + } + return out + } catch { + return t + } +} diff --git a/src/lib/parseGithubUrl.ts b/src/lib/parseGithubUrl.ts new file mode 100644 index 0000000..25efc7c --- /dev/null +++ b/src/lib/parseGithubUrl.ts @@ -0,0 +1,32 @@ +export type ParsedGithubUrl = { owner: string; repo: string; treePath?: string } + +/** Accept pasted browser URLs or `owner/repo` shorthand. */ +export function parseGithubUrl(raw: string): ParsedGithubUrl | null { + const s = raw.trim() + if (!s) return null + + if (!/^https?:\/\//i.test(s)) { + const bare = s.split(/[?#]/)[0].replace(/\/$/, '') + const bm = bare.match(/^([^/]+)\/([^/]+)$/) + if (bm) { + return { owner: bm[1], repo: bm[2].replace(/\.git$/, '') } + } + } + + const withScheme = /^https?:\/\//i.test(s) ? s : `https://${s}` + const noQuery = withScheme.split(/[?#]/)[0].replace(/\/$/, '') + + const gh = noQuery.match(/github\.com\/([^/]+)\/([^/]+)/i) + if (!gh) return null + const owner = gh[1] + const repo = gh[2].replace(/\.git$/, '') + + const lower = noQuery.toLowerCase() + const treeMarker = '/tree/' + const idx = lower.indexOf(treeMarker) + if (idx === -1) return { owner, repo } + + const rest = noQuery.slice(idx + treeMarker.length) + if (!rest) return { owner, repo } + return { owner, repo, treePath: rest } +} diff --git a/src/lib/untarGz.ts b/src/lib/untarGz.ts new file mode 100644 index 0000000..eb38d52 --- /dev/null +++ b/src/lib/untarGz.ts @@ -0,0 +1,60 @@ +import pako from 'pako' + +function basenameKey(path: string): string { + const parts = path.replace(/^\.\//, '').split('/') + return parts[parts.length - 1] ?? path +} + +/** Minimal ustar tar reader after gzip inflate. */ +export function extractTarGz(gz: Uint8Array): Map { + const tar = pako.inflate(gz) + const out = new Map() + const dec = new TextDecoder() + let off = 0 + + while (off + 512 <= tar.length) { + const header = tar.subarray(off, off + 512) + off += 512 + + const name = dec.decode(header.subarray(0, 100)).split('\0')[0].trim() + if (!name) break + + const typeflag = dec.decode(header.subarray(156, 157)) + const sizeField = dec.decode(header.subarray(124, 136)).split('\0')[0].trim() + const size = parseInt(sizeField, 8) || 0 + const prefix = dec.decode(header.subarray(345, 500)).split('\0')[0].trim() + const path = (prefix ? `${prefix}/${name}` : name).replace(/^\.\//, '') + + const pad = (512 - (size % 512)) % 512 + + if (typeflag === '0' || typeflag === '\0' || typeflag === '') { + out.set(path, new Uint8Array(tar.subarray(off, off + size))) + } + + off += size + pad + } + + return out +} + +export function findInTar(files: Map, filename: string): Uint8Array | undefined { + const lower = filename.toLowerCase() + for (const [k, v] of files) { + if (basenameKey(k).toLowerCase() === lower) return v + } + return undefined +} + +export type FlashManifestImage = { file: string; offset: number | string } + +export type FlashManifest = { images: FlashManifestImage[] } + +export function parseFlashManifest(json: string): FlashManifest | null { + try { + const o = JSON.parse(json) as FlashManifest + if (!o || !Array.isArray(o.images)) return null + return o + } catch { + return null + } +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..c2df941 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,21 @@ +import { ConvexAuthProvider } from "@convex-dev/auth/react" +import { ConvexReactClient } from "convex/react" +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import { BrowserRouter } from "react-router-dom" +import { Toaster } from "sonner" +import App from "./App" +import "./index.css" + +const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string) + +createRoot(document.getElementById("root")!).render( + + + + + + + + +) diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx new file mode 100644 index 0000000..760afab --- /dev/null +++ b/src/pages/AdminPage.tsx @@ -0,0 +1,140 @@ +import { Button } from '@/components/ui/button' +import { api } from '@/convex/_generated/api' +import type { Id } from '@/convex/_generated/dataModel' +import { useMutation, useQuery } from 'convex/react' +import { Link } from 'react-router-dom' +import { toast } from 'sonner' + +export default function AdminPage() { + const isAdmin = useQuery(api.admin.isAdmin) + const failedBuilds = useQuery(api.admin.listFailedRepoBuilds) + const failedScans = useQuery(api.admin.listFailedRepoScans) + const delBuild = useMutation(api.admin.deleteRepoBuild) + const delScan = useMutation(api.admin.deleteFailedScan) + + if (isAdmin === undefined) { + return ( +
+

Loading…

+
+ ) + } + + if (isAdmin === false) { + return ( +
+
+

Access denied

+

Admin only.

+ +
+
+ ) + } + + const removeBuild = async (id: Id<'repoBuilds'>) => { + try { + await delBuild({ buildId: id }) + toast.success('Build row deleted') + } catch (e) { + toast.error(String(e)) + } + } + + const removeScan = async (id: Id<'repoRefScan'>) => { + try { + await delScan({ scanId: id }) + toast.success('Scan row deleted') + } catch (e) { + toast.error(String(e)) + } + } + + return ( +
+
+

Admin

+

+ Clear failed repoBuilds to allow a new dispatch for the same + SHA+env. Clear failed repoRefScan to retry scanning a commit. +

+
+ +
+

Failed builds

+ {failedBuilds === undefined ? ( +

Loading…

+ ) : failedBuilds.length === 0 ? ( +

None.

+ ) : ( +
    + {failedBuilds.map(b => ( +
  • +
    +
    + + {b.owner}/{b.repo} + {' '} + · env{' '} + {b.targetEnv} +
    +
    {b.buildKey}
    + {b.githubRunId ? ( + + Actions run #{b.githubRunId} + + ) : null} + {b.errorSummary ?

    {b.errorSummary}

    : null} +
    + +
  • + ))} +
+ )} +
+ +
+

Failed scans

+ {failedScans === undefined ? ( +

Loading…

+ ) : failedScans.length === 0 ? ( +

None.

+ ) : ( +
    + {failedScans.map(s => ( +
  • +
    +
    + + {s.owner}/{s.repo} + +
    +
    {s.resolvedSourceSha}
    + {s.scanError ?

    {s.scanError}

    : null} +
    + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..3ef252e --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,115 @@ +import { Button } from '@/components/ui/button' +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { parseGithubUrl } from '../lib/parseGithubUrl' + +function encodeBranchPath(ref: string) { + return ref.split('/').map(encodeURIComponent).join('/') +} + +const DEMO_REPOS: { label: string; owner: string; repo: string; githubUrl: string }[] = [ + { + label: 'meshtastic/firmware', + owner: 'meshtastic', + repo: 'firmware', + githubUrl: 'https://github.com/meshtastic/firmware', + }, + { + label: 'meshcore-dev/MeshCore', + owner: 'meshcore-dev', + repo: 'MeshCore', + githubUrl: 'https://github.com/meshcore-dev/MeshCore', + }, +] + +export default function HomePage() { + const navigate = useNavigate() + const [input, setInput] = useState('') + const [error, setError] = useState(null) + + const go = () => { + const parsed = parseGithubUrl(input) + if (!parsed) { + setError('Paste a GitHub URL like https://github.com/owner/repo or owner/repo') + return + } + setError(null) + const o = encodeURIComponent(parsed.owner) + const r = encodeURIComponent(parsed.repo) + if (parsed.treePath) { + const path = encodeBranchPath(parsed.treePath) + navigate(`/${o}/${r}/tree/${path}`) + } else { + navigate(`/${o}/${r}`) + } + } + + return ( +
+
+
+

+ Mesh Forge +

+

+ Browse a GitHub PlatformIO repo, scan environments, trigger a CI build — then flash the bundle over USB + from the same page with Web Serial (Chromium). +

+
+ +
+ + setInput(e.target.value)} + onKeyDown={e => e.key === 'Enter' && go()} + /> + {error ?

{error}

: null} + +
+

Try a demo

+ +
+
+ +

+ URLs mirror GitHub: /owner/repo/tree/ref. Short form{' '} + owner/repo uses the default branch. +

+
+
+ ) +} diff --git a/src/pages/LegalLicensePage.tsx b/src/pages/LegalLicensePage.tsx new file mode 100644 index 0000000..fda904b --- /dev/null +++ b/src/pages/LegalLicensePage.tsx @@ -0,0 +1,6 @@ +import MarkdownDoc from '../components/MarkdownDoc' +import content from '../content/license.md?raw' + +export default function LegalLicensePage() { + return +} diff --git a/src/pages/LegalPrivacyPage.tsx b/src/pages/LegalPrivacyPage.tsx new file mode 100644 index 0000000..854709a --- /dev/null +++ b/src/pages/LegalPrivacyPage.tsx @@ -0,0 +1,6 @@ +import MarkdownDoc from '../components/MarkdownDoc' +import content from '../content/privacy.md?raw' + +export default function LegalPrivacyPage() { + return +} diff --git a/src/pages/LegalTermsPage.tsx b/src/pages/LegalTermsPage.tsx new file mode 100644 index 0000000..b4cc228 --- /dev/null +++ b/src/pages/LegalTermsPage.tsx @@ -0,0 +1,6 @@ +import MarkdownDoc from '../components/MarkdownDoc' +import content from '../content/terms.md?raw' + +export default function LegalTermsPage() { + return +} diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..165affe --- /dev/null +++ b/src/pages/NotFoundPage.tsx @@ -0,0 +1,16 @@ +import { Button } from '@/components/ui/button' +import { Link } from 'react-router-dom' + +export default function NotFoundPage() { + return ( +
+

Page not found

+

That URL does not match anything in Mesh Forge.

+
+ +
+
+ ) +} diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx new file mode 100644 index 0000000..1497506 --- /dev/null +++ b/src/pages/RepoPage.tsx @@ -0,0 +1,437 @@ +import { AutocompleteField } from '../components/AutocompleteField' +import EspFlasher from '../components/EspFlasher' +import { Button } from '@/components/ui/button' +import { api } from '@/convex/_generated/api' +import { useAction, useMutation, useQuery } from 'convex/react' +import { Github, Link2, RefreshCw } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { homepageHref, homepageLabel } from '../lib/githubHomepage' +import ReactMarkdown from 'react-markdown' +import { Link, Navigate, useNavigate, useParams } from 'react-router-dom' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import remarkGfm from 'remark-gfm' +import { toast } from 'sonner' +import { normalizeBuildKey } from '../lib/buildKey' +import { formatBuildErrorSummary } from '../lib/formatBuildErrorSummary' + +export default function RepoPage() { + const navigate = useNavigate() + const params = useParams<{ owner: string; repo: string; '*': string }>() + const ownerParam = params.owner ?? '' + const repoParam = params.repo ?? '' + const treePath = params['*'] + const owner = useMemo(() => decodeURIComponent(ownerParam), [ownerParam]) + const repo = useMemo(() => decodeURIComponent(repoParam), [repoParam]) + const onShortUrl = !treePath + + const branchData = useQuery( + api.repoBranches.get, + owner && repo ? { owner, repo } : 'skip' + ) + const refreshBranches = useAction(api.repoBranches.refresh) + const resolveRef = useAction(api.repoScans.resolveRefToSha) + const fetchReadme = useAction(api.repoBranches.fetchReadme) + const ensureScan = useMutation(api.repoScans.ensureScan) + const ensureBuild = useMutation(api.repoBuilds.ensureBuild) + const getSignedUrl = useAction(api.repoBuildDownloads.getSignedDownloadUrl) + const effectiveRef = useMemo(() => { + if (treePath) { + return treePath + .split('/') + .filter(Boolean) + .map(p => decodeURIComponent(p)) + .join('/') + } + return branchData?.row?.defaultBranch ?? null + }, [treePath, branchData?.row?.defaultBranch]) + + useEffect(() => { + if (!owner || !repo || branchData === undefined) return + if (branchData.row !== null && !branchData.isStale) return + void refreshBranches({ owner, repo }).catch(e => toast.error(String(e))) + }, [owner, repo, branchData, refreshBranches]) + + const [resolvedSha, setResolvedSha] = useState(null) + const [refError, setRefError] = useState(null) + useEffect(() => { + if (!owner || !repo || !effectiveRef) return + let cancelled = false + setResolvedSha(null) + setRefError(null) + void resolveRef({ owner, repo, ref: effectiveRef }) + .then(sha => { + if (!cancelled) setResolvedSha(sha) + }) + .catch(e => { + if (!cancelled) setRefError(String(e)) + }) + return () => { + cancelled = true + } + }, [owner, repo, effectiveRef, resolveRef]) + + useEffect(() => { + if (!owner || !repo || !effectiveRef || !resolvedSha) return + void ensureScan({ owner, repo, ref: effectiveRef, resolvedSourceSha: resolvedSha }).catch(e => + toast.error(String(e)) + ) + }, [owner, repo, effectiveRef, resolvedSha, ensureScan]) + + const scan = useQuery( + api.repoScans.getByRepoSha, + resolvedSha ? { owner, repo, resolvedSourceSha: resolvedSha } : 'skip' + ) + + const [readmeMd, setReadmeMd] = useState(null) + useEffect(() => { + if (!effectiveRef) return + let cancelled = false + setReadmeMd(null) + void fetchReadme({ owner, repo, ref: effectiveRef }) + .then(r => { + if (!cancelled) setReadmeMd(r.markdown) + }) + .catch(() => { + if (!cancelled) setReadmeMd('*(README could not be loaded.)*') + }) + return () => { + cancelled = true + } + }, [owner, repo, effectiveRef, fetchReadme]) + + const envNames = scan?.scanStatus === 'complete' ? scan.envNames ?? [] : [] + const [selectedEnv, setSelectedEnv] = useState('') + useEffect(() => { + if (!envNames.length) return + if (!selectedEnv || !envNames.includes(selectedEnv)) { + setSelectedEnv(envNames[0]) + } + }, [envNames, selectedEnv]) + + const [branchDraft, setBranchDraft] = useState('') + useEffect(() => { + if (!effectiveRef) return + setBranchDraft(effectiveRef) + }, [effectiveRef]) + + const [envDraft, setEnvDraft] = useState('') + useEffect(() => { + if (selectedEnv) setEnvDraft(selectedEnv) + }, [selectedEnv]) + + const buildKey = + resolvedSha && selectedEnv ? normalizeBuildKey(resolvedSha, selectedEnv) : null + const build = useQuery(api.repoBuilds.getByBuildKey, buildKey ? { buildKey } : 'skip') + + const [flashUrl, setFlashUrl] = useState(null) + const [flashPrep, setFlashPrep] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + + useEffect(() => { + if (!build?._id || build.status !== 'succeeded') { + setFlashUrl(null) + setFlashPrep('idle') + return + } + let cancelled = false + setFlashPrep('loading') + setFlashUrl(null) + void getSignedUrl({ buildId: build._id }) + .then(url => { + if (cancelled) return + setFlashUrl(url) + setFlashPrep('ready') + }) + .catch(e => { + if (cancelled) return + toast.error(String(e)) + setFlashPrep('error') + }) + return () => { + cancelled = true + } + }, [build?._id, build?.status, getSignedUrl]) + + const queueFlashArtifacts = () => { + if (!effectiveRef || !resolvedSha || !selectedEnv) return + void ensureBuild({ + owner, + repo, + ref: effectiveRef, + resolvedSourceSha: resolvedSha, + targetEnv: selectedEnv, + }).catch(e => toast.error(String(e))) + } + + const download = async () => { + if (!build?._id) return + try { + const url = await getSignedUrl({ buildId: build._id }) + window.open(url, '_blank', 'noopener,noreferrer') + } catch (e) { + toast.error(String(e)) + } + } + + if (onShortUrl) { + if (branchData === undefined) { + return ( +
+ Resolving default branch… +
+ ) + } + if (!branchData.row) { + return ( +
+

Could not load branch list. The repository may be private or missing.

+ +
+ ) + } + const enc = branchData.row.defaultBranch.split('/').map(encodeURIComponent).join('/') + return + } + + if (!effectiveRef) { + return ( +
Loading…
+ ) + } + + const ghTree = `https://github.com/${owner}/${repo}/tree/${effectiveRef.split('/').map(encodeURIComponent).join('/')}` + + const branchNames = branchData?.row?.branches.map(b => b.name) ?? [] + let branchOptions = + effectiveRef && !branchNames.includes(effectiveRef) + ? [effectiveRef, ...branchNames] + : [...branchNames] + if (branchOptions.length === 0 && effectiveRef) branchOptions = [effectiveRef] + + const ghAboutDescription = branchData?.row?.description?.trim() ?? '' + const ghAboutHomepage = branchData?.row?.homepage?.trim() ?? '' + + const scanReady = Boolean(resolvedSha && scan?.scanStatus === 'complete' && envNames.length > 0) + const flashPrimaryDisabled = + !resolvedSha || + Boolean(refError) || + !selectedEnv || + !envNames.includes(selectedEnv) || + !scanReady + + const ghRepoRoot = `https://github.com/${owner}/${repo}` + + const targetPlaceholder = !resolvedSha + ? '…' + : scan == null || scan.scanStatus === 'in_progress' + ? 'Scanning…' + : scan.scanStatus === 'failed' + ? 'Scan failed' + : envNames.length === 0 + ? 'No targets' + : 'Pick env…' + + return ( +
+
+
+
+
+ { + setBranchDraft(v) + if (branchOptions.includes(v)) { + const enc = v.split('/').map(encodeURIComponent).join('/') + navigate(`/${ownerParam}/${repoParam}/tree/${enc}`) + } + }} + onBlur={() => { + if (!branchOptions.includes(branchDraft)) setBranchDraft(effectiveRef) + }} + disabled={branchOptions.length === 0} + /> + {scanReady && envNames.length > 0 ? ( + { + setEnvDraft(v) + if (envNames.includes(v)) setSelectedEnv(v) + }} + onBlur={() => { + if (!envNames.includes(envDraft)) setEnvDraft(selectedEnv) + }} + disabled={false} + /> + ) : ( + + )} + + +
+ +
+ {branchData?.isStale ? Branch list may be stale. : null} + {refError ? {refError} : null} + {!refError && !resolvedSha ? Resolving branch… : null} + {resolvedSha && (scan == null || scan.scanStatus === 'in_progress') ? ( + Scanning PlatformIO… + ) : null} + {resolvedSha && scan?.scanStatus === 'failed' ? ( + Scan failed: {scan.scanError ?? 'unknown'} + ) : null} +
+ +
+ {build ? ( +
+
+ CI + {build.status} + {build.githubRunId ? ( + + Workflow run + + ) : null} +
+ {build.status === 'failed' && build.errorSummary ? ( +

{formatBuildErrorSummary(build.errorSummary)}

+ ) : null} + {build.status === 'succeeded' ? ( + + ) : null} +
+ ) : null} + + {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 ? ( + + ) : null} +
+ +
+ {readmeMd === null ? ( +

Loading…

+ ) : ( + + {readmeMd || '*No README.*'} + + )} +
+
+ + +
+
+
+ ) +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..b5b4546 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,6 @@ +/// + +declare module '*.md?raw' { + const src: string + export default src +} diff --git a/tsconfig.json b/tsconfig.json index 317d47d..a5a2062 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "module": "ES2022", "moduleResolution": "Bundler", "lib": ["DOM", "DOM.Iterable", "ESNext"], - "types": ["vite/client", "vike-react"], + "types": ["vite/client"], "noEmit": true, "skipLibCheck": true, "esModuleInterop": true, diff --git a/vendor/meshcore-firmware b/vendor/meshcore-firmware index eeae32b..bfd4800 160000 --- a/vendor/meshcore-firmware +++ b/vendor/meshcore-firmware @@ -1 +1 @@ -Subproject commit eeae32b25db102f55761d471ab4667e52bf3dbf5 +Subproject commit bfd4800f5985049be1cbb8785d129e7680b89771 diff --git a/vite.config.ts b/vite.config.ts index 1fa537d..5d817a7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,23 +1,18 @@ -import mdx from "@mdx-js/rollup" import tailwindcss from "@tailwindcss/vite" import react from "@vitejs/plugin-react" import path from "node:path" -import remarkGfm from "remark-gfm" -import vike from "vike/plugin" import { defineConfig } from "vite" export default defineConfig({ - plugins: [ - vike(), - mdx({ - remarkPlugins: [remarkGfm], - }), - react(), - tailwindcss(), - ], + plugins: [react(), tailwindcss()], resolve: { alias: { "@": path.resolve(__dirname, "."), }, }, + build: { + outDir: "dist", + emptyOutDir: true, + }, + publicDir: "public", })