21 Commits
v0.1.0 ... main

Author SHA1 Message Date
Ben Allfree
663fa54096 fix: update Mesh Forge links for consistency in documentation 2025-12-10 22:46:07 -08:00
Ben Allfree
56d13d3e08 feat: enhance plugin configuration with diagnostics options and refactor build hash computation 2025-12-10 20:03:59 -08:00
Ben Allfree
7349d81102 chore: remove lofs-test submodule from .gitmodules 2025-12-10 18:55:26 -08:00
Ben Allfree
c75aab0c14 chore: remove meshtastic remote addition from deploy workflow 2025-12-10 17:52:06 -08:00
Ben Allfree
fa7eb6ea32 v0.4.0 2025-12-10 17:48:29 -08:00
Ben Allfree
bdb848e15f fix: refactor build routes for improved navigation 2025-12-10 17:47:38 -08:00
Ben Allfree
cdc4959dca feat: introduce vendors.json for mapping vendors to models and refactor targets.ts to utilize new data structure 2025-12-10 17:39:10 -08:00
Ben Allfree
c38e6b735c chore: remove unused submodules for web-flasher, firmware, api, and meshcore from .gitmodules 2025-12-10 17:27:25 -08:00
Ben Allfree
de43b1b024 fix: relocate ArtifactType enum to client-safe location to resolve import issues in browser 2025-12-10 13:49:05 -08:00
Ben Allfree
f1e5390afc v0.3.0 2025-12-10 13:38:11 -08:00
Ben Allfree
b1ac3abf2f fix: prerendering 2025-12-10 13:36:12 -08:00
Ben Allfree
305ccc64a9 feat: add footer with legal links and new pages for License, Privacy Policy, and Terms of Service 2025-12-10 12:23:29 -08:00
Ben Allfree
ef6196ab7e chore: add new submodule for meshcore-flasher to .gitmodules 2025-12-10 09:07:26 -08:00
Ben Allfree
665989a41a chore: add guideline to avoid git operations during release preparation 2025-12-10 09:07:21 -08:00
Ben Allfree
daee517764 chore: update version to 0.2.0 2025-12-10 09:07:15 -08:00
Ben Allfree
9b7661562f feat: switch OAuth authentication provider from Google to GitHub for improved sign-in functionality 2025-12-10 05:44:34 -08:00
Ben Allfree
154bee2e8c feat: integrate Giscus comments into build pages for enhanced discussion and support 2025-12-10 05:24:06 -08:00
Ben Allfree
e931c217cd chore: clarify changelog entry instructions and update changelog usage guidelines 2025-12-10 05:17:46 -08:00
Ben Allfree
431d91f511 chore: remove Changesets configuration and README files 2025-12-10 05:17:37 -08:00
Ben Allfree
b80b42f1c0 feat: make build hash label clickable in BuildProgress component for navigation to build details 2025-12-10 05:17:28 -08:00
Ben Allfree
10ba16a9a4 Firmware source code updated to latest version 2025-12-10 04:24:42 -08:00
57 changed files with 2348 additions and 1401 deletions

View File

@@ -1,8 +0,0 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View File

@@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}

View File

@@ -2,6 +2,10 @@
alwaysApply: true
---
when making changes to a module that has a changelog, add the appropriate unreleased change log entry
when making changes to a project that has a changelog, add the appropriate unreleased change log entry
when preparing an MPM plugin release, don't forget to update @public/registry.json
changelog uses: major/minor/patch and does NOT use added/changed/removed
when preparing a release, do not touch git. no tagging, no commits

View File

@@ -15,11 +15,6 @@ jobs:
submodules: recursive
fetch-depth: 0
- name: Add meshtastic remote to vendor/firmware
run: |
git remote add meshtastic git@github.com:meshtastic/firmware.git
working-directory: vendor/firmware
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:

19
.gitmodules vendored
View File

@@ -1,13 +1,3 @@
[submodule "vendor/web-flasher"]
path = vendor/web-flasher
url = https://github.com/meshtastic/web-flasher
[submodule "vendor/firmware"]
path = vendor/firmware
url = https://github.com/MeshEnvy/firmware.git
branch = meshenvy/module-registry
[submodule "vendor/api"]
path = vendor/api
url = https://github.com/meshtastic/api.git
[submodule "vendor/mpm"]
path = vendor/mpm
url = git@github.com:MeshEnvy/mpm.git
@@ -17,9 +7,12 @@
[submodule "vendor/lodb"]
path = vendor/lodb
url = git@github.com:MeshEnvy/lodb.git
[submodule "vendor/meshcore"]
path = vendor/meshcore
url = git@github.com:MeshEnvy/MeshCore.git
[submodule "vendor/meshscript"]
path = vendor/meshscript
url = git@github.com:MeshEnvy/meshscript.git
[submodule "vendor/meshcore-flasher"]
path = vendor/meshcore-flasher
url = git@github.com:meshcore-dev/flasher.meshcore.dev.git
[submodule "vendor/lofs"]
path = vendor/lofs
url = git@github.com:MeshEnvy/lofs.git

View File

@@ -7,9 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.4.0] - 2025-12-10
### Minor
- Added vendors.json mapping vendors to models and platformio targets
- Refactored targets.ts to use vendors.json and architecture-hierarchy.json instead of hardware-list.json
- Updated architecture-hierarchy.json generation to use actual PlatformIO environment names (removed normalization)
- Removed normalization from lib/utils.ts since all inputs now use standardized PlatformIO names
- Refactored build routes from dynamic parameterized routes to query string parameters for Vike SSG compatibility
- Refactored Builder component into smaller reusable components (BuilderHeader, TargetSelector, VersionSelector, ModuleConfig, PluginConfig, BuildActions)
- Extracted target selection and plugin compatibility logic into reusable hooks (useTargetSelection, usePluginCompatibility)
### Patch
- Fix Convex server functions being imported in browser by moving ArtifactType enum to client-safe location
- Fix nested anchor tag hydration error in PluginCard component by converting nested links to buttons when parent is a link
## [0.3.0] - 2025-12-10
### Minor
- Added footer with links to legal pages (License, Privacy Policy, Terms of Service)
- Added licensing notice page explaining GPLv3 licensing for generated projects
- Added privacy policy page detailing data collection and usage
- Added terms of service page with usage terms and disclaimers
### Patch
- Fix prerendering on dynamic routes
## [0.2.0] - 2025-12-10
### Minor
- Switched OAuth authentication provider from Google to GitHub
- Integrated Giscus comments into build pages for discussion threads per build configuration
### Patch
- Made build hash label clickable in BuildProgress component to navigate to build detail page
## [0.1.0] - 2025-12-10
Initial release
[Unreleased]: https://github.com/MeshEnvy/mesh-forge/compare/v0.1.0...HEAD
[Unreleased]: https://github.com/MeshEnvy/mesh-forge/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.4.0
[0.3.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.3.0
[0.2.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.2.0
[0.1.0]: https://github.com/MeshEnvy/mesh-forge/releases/tag/v0.1.0

View File

@@ -0,0 +1,34 @@
import { Button } from "@/components/ui/button"
import { Loader2 } from "lucide-react"
interface BuildActionsProps {
selectedTargetLabel: string
isFlashing: boolean
isFlashDisabled: boolean
errorMessage: string | null
onFlash: () => void
}
export function BuildActions({
selectedTargetLabel,
isFlashing,
isFlashDisabled,
errorMessage,
onFlash,
}: BuildActionsProps) {
return (
<div className="space-y-2">
<Button onClick={onFlash} disabled={isFlashDisabled} className="w-full bg-cyan-600 hover:bg-cyan-700">
{isFlashing ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Queuing build...
</span>
) : (
`Flash ${selectedTargetLabel || ""}`.trim() || "Flash"
)}
</Button>
{errorMessage && <p className="text-sm text-red-400">{errorMessage}</p>}
</div>
)
}

View File

@@ -2,7 +2,7 @@ import { SourceAvailable } from "@/components/SourceAvailable"
import { Button } from "@/components/ui/button"
import { api } from "@/convex/_generated/api"
import type { Doc } from "@/convex/_generated/dataModel"
import { ArtifactType } from "@/convex/builds"
import { ArtifactType } from "@/convex/lib/filename"
import { useMutation } from "convex/react"
import { useState } from "react"
import { toast } from "sonner"

View File

@@ -2,12 +2,12 @@ import { BuildDownloadButton } from "@/components/BuildDownloadButton"
import { Button } from "@/components/ui/button"
import { TARGETS } from "@/constants/targets"
import type { Doc } from "@/convex/_generated/dataModel"
import { ArtifactType } from "@/convex/builds"
import { getArtifactFilenameBase } from "@/convex/lib/filename"
import { ArtifactType, getArtifactFilenameBase } from "@/convex/lib/filename"
import { computeFlagsFromConfig } from "@/convex/lib/flags"
import modulesData from "@/convex/modules.json"
import { getImplicitDependencies, humanizeStatus } from "@/lib/utils"
import registryData from "@/public/registry.json"
import { AlertCircle, CheckCircle, Copy, Loader2, Share2, X, XCircle } from "lucide-react"
import { CheckCircle, Copy, Loader2, Share2, X, XCircle } from "lucide-react"
import { useState } from "react"
import { toast } from "sonner"
import { navigate } from "vike/client/router"
@@ -49,7 +49,7 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
? `https://github.com/MeshEnvy/mesh-forge/actions/runs/${build.githubRunId}`
: null
const shareUrl = `${window.location.origin}/builds/new/${build.buildHash}`
const shareUrl = `${window.location.origin}/builds?hash=${build.buildHash}`
const handleShare = async () => {
try {
@@ -65,7 +65,10 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
}
const generateBashCommand = (): string => {
const flags = computeFlagsFromConfig(build.config)
const flags = computeFlagsFromConfig(
build.config,
registryData as Record<string, { configOptions?: Record<string, { define: string }> }>
)
const target = build.config.target
const version = build.config.version
const plugins = build.config.pluginsEnabled || []
@@ -103,9 +106,8 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
}
// Set build flags and build
if (flags) {
commands.push(`export PLATFORMIO_BUILD_FLAGS="${flags}"`)
}
// Always export PLATFORMIO_BUILD_FLAGS (even if empty) so users can see what was used
commands.push(`export PLATFORMIO_BUILD_FLAGS="${flags || ""}"`)
commands.push(`pio run -e ${target}`)
return commands.join("\n")
@@ -130,69 +132,6 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
}
}
// Compute build flags from config (same logic as computeFlagsFromConfig in convex/builds.ts)
const computeFlagsFromConfig = (config: typeof build.config): string => {
return Object.keys(config.modulesExcluded)
.sort()
.filter(module => config.modulesExcluded[module])
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
.join(" ")
}
// Generate GitHub discussion URL with prefilled body
const generateDiscussionUrl = (): string => {
const flags = computeFlagsFromConfig(build.config)
const plugins = build.config.pluginsEnabled?.join(", ") || "(none)"
const timestamp = new Date(build.startedAt).toISOString()
const githubRunLink = githubActionUrl ? `[View run](${githubActionUrl})` : "(not available)"
const buildPageUrl = `${window.location.origin}/builds/${build.buildHash}`
// Format plugins as +plugin@version
const formattedPlugins =
build.config.pluginsEnabled
?.map(plugin => {
// Plugin might be "slug@version" or just "slug"
return plugin.includes("@") ? `+${plugin}` : `+${plugin}`
})
.join(" ") || ""
const bracketContent = [
build.config.target,
`v${build.config.version}`,
...(formattedPlugins ? [formattedPlugins] : []),
].join(" ")
const discussionTitle = `Build ${build.status === "failure" ? "Failed" : "Issue"}: ${targetLabel} [${bracketContent}]`
const discussionBody = `## Build ${build.status === "failure" ? "Failed" : "Information"}
**Build Hash**: \`${build.buildHash}\`
**Target Board**: ${build.config.target}
**Firmware Version**: ${build.config.version}
**Build Flags**: ${flags || "(none)"}
**Plugins**: ${plugins}
**Build Timestamp**: ${timestamp}
**Build Page**: [View build page](${buildPageUrl})
**GitHub Run**: ${githubRunLink}
## Additional Information
(Please add any additional details about the issue here)`
const baseUrl = "https://github.com/MeshEnvy/mesh-forge/discussions/new"
const params = new URLSearchParams({
category: "q-a",
title: discussionTitle,
body: discussionBody,
})
return `${baseUrl}?${params.toString()}`
}
const handleReportIssue = () => {
window.open(generateDiscussionUrl(), "_blank", "noopener,noreferrer")
}
const handleRetry = async () => {
if (!build?._id || !onRetry) return
try {
@@ -270,7 +209,16 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
<div>
<h2 className="text-2xl font-semibold mb-2 flex items-center gap-2">
{getStatusIcon()}
{targetLabel}
<a
href={`/builds?hash=${build.buildHash}`}
onClick={e => {
e.preventDefault()
navigate(`/builds?hash=${build.buildHash}`)
}}
className="hover:text-cyan-400 transition-colors"
>
{targetLabel}
</a>
{status !== "success" && status !== "failure" && (
<button
type="button"
@@ -302,12 +250,8 @@ export function BuildProgress({ build, isAdmin = false, onRetry, showActions = t
</div>
{showActions && (
<div className="flex gap-2">
<Button onClick={handleReportIssue} variant="outline" className="border-slate-600 hover:bg-slate-800">
<AlertCircle className="w-4 h-4 mr-2" />
Support
</Button>
<Button
onClick={() => navigate(`/builds/new/${build.buildHash}`)}
onClick={() => navigate(`/builds?clone=${build.buildHash}`)}
variant="outline"
className="border-slate-600 hover:bg-slate-800"
aria-label="Clone"

347
components/Builder.tsx Normal file
View File

@@ -0,0 +1,347 @@
import { BuildActions } from "@/components/BuildActions"
import { BuilderHeader } from "@/components/BuilderHeader"
import { ModuleConfig } from "@/components/ModuleConfig"
import { PluginConfig } from "@/components/PluginConfig"
import { TargetSelector } from "@/components/TargetSelector"
import { VersionSelector } from "@/components/VersionSelector"
import { TARGETS } from "@/constants/targets"
import { VERSIONS } from "@/constants/versions"
import { api } from "@/convex/_generated/api"
import { usePluginCompatibility } from "@/hooks/usePluginCompatibility"
import { useTargetSelection } from "@/hooks/useTargetSelection"
import { getDependedPlugins, getImplicitDependencies, isRequiredByOther } from "@/lib/utils"
import registryData from "@/public/registry.json"
import { useMutation, useQuery } from "convex/react"
import { Loader2 } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { navigate } from "vike/client/router"
interface BuilderProps {
cloneHash?: string
pluginParam?: string
}
export default function Builder({ cloneHash, pluginParam }: BuilderProps) {
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
const sharedBuild = useQuery(api.builds.getByHash, cloneHash ? { buildHash: cloneHash } : "skip")
const preselectedPlugin =
pluginParam && pluginParam in registryData
? (
registryData as Record<
string,
{ includes?: string[]; name: string; description: string; imageUrl?: string; featured?: boolean }
>
)[pluginParam]
: null
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
const [pluginOptionsConfig, setPluginOptionsConfig] = useState<Record<string, Record<string, boolean>>>({})
const [isFlashing, setIsFlashing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
const [showPlugins, setShowPlugins] = useState(true)
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
// Get all enabled plugins
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Compute all enabled plugins (explicit + implicit)
const allEnabledPlugins = getDependedPlugins(
enabledPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Calculate plugin compatibility
const { compatibleTargets, filteredGroupedTargets, filteredTargetCategories } = usePluginCompatibility(
enabledPlugins,
preselectedPlugin
)
// Target selection logic
const { activeCategory, selectedTarget, setActiveCategory, handleSelectTarget, TARGET_CATEGORIES, GROUPED_TARGETS } =
useTargetSelection(compatibleTargets, filteredGroupedTargets, filteredTargetCategories)
// Preselect plugin from URL parameter
useEffect(() => {
if (pluginParam && preselectedPlugin && !cloneHash) {
setPluginConfig({ [pluginParam]: true })
setShowPlugins(true)
}
}, [pluginParam, preselectedPlugin, cloneHash])
// Pre-populate form from shared build
useEffect(() => {
if (!cloneHash) return
if (sharedBuild === undefined) {
setIsLoadingSharedBuild(true)
return
}
setIsLoadingSharedBuild(false)
if (!sharedBuild) {
setErrorMessage("Build not found. The shared build may have been deleted.")
toast.error("Build not found", {
description: "The shared build could not be loaded.",
})
return
}
const config = sharedBuild.config
if (config.target && TARGETS[config.target]) {
handleSelectTarget(config.target)
const category = TARGETS[config.target].category || "Other"
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
if (config.version && (VERSIONS as readonly string[]).includes(config.version)) {
setSelectedVersion(config.version as (typeof VERSIONS)[number])
}
if (config.modulesExcluded) {
setModuleConfig(config.modulesExcluded)
if (Object.keys(config.modulesExcluded).length > 0) {
setShowModuleOverrides(true)
}
}
if (config.pluginsEnabled && config.pluginsEnabled.length > 0) {
const allPluginSlugs = config.pluginsEnabled.map(pluginId => {
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
})
const requiredByOthers = new Set<string>()
for (const pluginSlug of allPluginSlugs) {
if (
isRequiredByOther(
pluginSlug,
allPluginSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
) {
requiredByOthers.add(pluginSlug)
}
}
const pluginObj: Record<string, boolean> = {}
allPluginSlugs.forEach(slug => {
if (slug in registryData && !requiredByOthers.has(slug)) {
pluginObj[slug] = true
}
})
setPluginConfig(pluginObj)
setShowPlugins(true)
}
if (config.pluginConfigs) {
setPluginOptionsConfig(config.pluginConfigs)
}
}, [cloneHash, sharedBuild, handleSelectTarget, setActiveCategory, TARGET_CATEGORIES])
const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
const handleToggleModule = (id: string, excluded: boolean) => {
setModuleConfig(prev => {
const next = { ...prev }
if (excluded) {
next[id] = true
} else {
delete next[id]
}
return next
})
}
const handleTogglePlugin = (id: string, enabled: boolean) => {
const explicitPlugins = Object.keys(pluginConfig).filter(pluginId => pluginConfig[pluginId] === true)
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
const isRequired = isRequiredByOther(
id,
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
if (implicitDeps.has(id)) {
return
}
if (!enabled && isRequired) {
return
}
setPluginConfig(prev => {
const next = { ...prev }
if (enabled) {
next[id] = true
} else {
delete next[id]
const remainingExplicit = Object.keys(next).filter(pluginId => next[pluginId] === true)
const allStillNeeded = getDependedPlugins(
remainingExplicit,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
for (const pluginId of Object.keys(next)) {
if (next[pluginId] === true && !allStillNeeded.includes(pluginId) && !remainingExplicit.includes(pluginId)) {
delete next[pluginId]
}
}
for (const pluginId of remainingExplicit) {
next[pluginId] = true
}
}
return next
})
}
const handleTogglePluginOption = (pluginId: string, optionKey: string, enabled: boolean) => {
setPluginOptionsConfig(prev => {
const next = { ...prev }
if (!next[pluginId]) {
next[pluginId] = {}
}
const pluginOptions = { ...next[pluginId] }
if (enabled) {
pluginOptions[optionKey] = true
} else {
delete pluginOptions[optionKey]
}
if (Object.keys(pluginOptions).length === 0) {
delete next[pluginId]
} else {
next[pluginId] = pluginOptions
}
return next
})
}
const handleFlash = async () => {
if (!selectedTarget) return
setIsFlashing(true)
setErrorMessage(null)
try {
const enabledSlugs = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
const implicitDeps = getImplicitDependencies(
enabledSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
const explicitOnlySlugs = enabledSlugs.filter(slug => !implicitDeps.has(slug))
const pluginsEnabled = explicitOnlySlugs.map(slug => {
const plugin = (registryData as Record<string, { version: string }>)[slug]
return `${slug}@${plugin.version}`
})
// Filter plugin config to only include enabled plugins
const filteredPluginConfig = Object.keys(pluginOptionsConfig).reduce(
(acc, pluginId) => {
if (allEnabledPlugins.includes(pluginId)) {
acc[pluginId] = pluginOptionsConfig[pluginId]
}
return acc
},
{} as Record<string, Record<string, boolean>>
)
const result = await ensureBuildFromConfig({
target: selectedTarget,
version: selectedVersion,
modulesExcluded: moduleConfig,
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
pluginConfigs: Object.keys(filteredPluginConfig).length > 0 ? filteredPluginConfig : undefined,
registryData: registryData,
})
navigate(`/builds?hash=${result.buildHash}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setErrorMessage("Failed to start build. Please try again.")
toast.error("Failed to start build", {
description: message,
})
} finally {
setIsFlashing(false)
}
}
if (isLoadingSharedBuild) {
return (
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10 flex items-center justify-center">
<div className="text-center space-y-4">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500 mx-auto" />
<p className="text-slate-400">Loading shared build configuration...</p>
</div>
</div>
)
}
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
return (
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10">
<div className="max-w-6xl mx-auto space-y-8">
<BuilderHeader preselectedPlugin={preselectedPlugin} />
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<TargetSelector
activeCategory={activeCategory}
categories={categories}
groupedTargets={compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS}
selectedTarget={selectedTarget}
compatibleTargets={compatibleTargets}
onCategoryChange={setActiveCategory}
onTargetSelect={handleSelectTarget}
/>
<VersionSelector selectedVersion={selectedVersion} onVersionChange={setSelectedVersion} />
<ModuleConfig
moduleConfig={moduleConfig}
showModuleOverrides={showModuleOverrides}
onToggleShow={() => setShowModuleOverrides(prev => !prev)}
onToggleModule={handleToggleModule}
onReset={() => setModuleConfig({})}
/>
<PluginConfig
pluginConfig={pluginConfig}
pluginOptionsConfig={pluginOptionsConfig}
selectedTarget={selectedTarget}
pluginParam={pluginParam}
pluginFlashCounts={pluginFlashCounts}
showPlugins={showPlugins}
onToggleShow={() => setShowPlugins(prev => !prev)}
onTogglePlugin={handleTogglePlugin}
onTogglePluginOption={handleTogglePluginOption}
onReset={() => {
setPluginConfig({})
setPluginOptionsConfig({})
}}
/>
<BuildActions
selectedTargetLabel={selectedTargetLabel}
isFlashing={isFlashing}
isFlashDisabled={!selectedTarget || isFlashing}
errorMessage={errorMessage}
onFlash={handleFlash}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { CheckCircle2 } from "lucide-react"
interface BuilderHeaderProps {
preselectedPlugin?: {
name: string
description: string
imageUrl?: string
featured?: boolean
includes?: string[]
} | null
}
export function BuilderHeader({ preselectedPlugin }: BuilderHeaderProps) {
return (
<>
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<p className="text-sm uppercase tracking-wider text-slate-500">
{preselectedPlugin ? "Plugin build" : "Quick build"}
</p>
<h1 className="text-4xl font-bold mt-1">
{preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"}
</h1>
<p className="text-slate-400 mt-2 max-w-2xl">
{preselectedPlugin
? `Select a compatible Meshtastic target and configure your build for ${preselectedPlugin.name}. We'll send you to the build status page as soon as it starts.`
: "Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to the build status page as soon as it starts."}
</p>
</div>
</div>
{preselectedPlugin && (
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="flex items-start gap-4">
<CheckCircle2 className="w-8 h-8 text-green-400 shrink-0 mt-1" />
<div className="flex items-start gap-4 flex-1">
{preselectedPlugin.imageUrl && (
<img
src={preselectedPlugin.imageUrl}
alt={`${preselectedPlugin.name} logo`}
className="w-16 h-16 rounded-lg object-contain shrink-0"
/>
)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-2xl font-bold">{preselectedPlugin.name}</h2>
{preselectedPlugin.featured && (
<span className="px-2 py-1 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
Featured
</span>
)}
</div>
<p className="text-slate-400 mb-3">{preselectedPlugin.description}</p>
{preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && (
<p className="text-sm text-slate-500">Compatible with: {preselectedPlugin.includes.join(", ")}</p>
)}
</div>
</div>
</div>
</div>
)}
</>
)
}

21
components/Footer.tsx Normal file
View File

@@ -0,0 +1,21 @@
export default function Footer() {
return (
<footer className="border-t border-slate-800 bg-slate-950 mt-auto">
<div className="max-w-7xl mx-auto px-8 py-6">
<div className="flex flex-wrap items-center justify-center gap-4 text-sm text-slate-400">
<a href="/license" className="hover:text-white transition-colors">
License
</a>
<span className="text-slate-600"></span>
<a href="/privacy" className="hover:text-white transition-colors">
Privacy Policy
</a>
<span className="text-slate-600"></span>
<a href="/terms" className="hover:text-white transition-colors">
Terms of Service
</a>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,55 @@
import type { Doc } from "@/convex/_generated/dataModel"
import { getBuildIdentifier } from "@/convex/lib/filename"
import { useEffect, useRef } from "react"
interface GiscusCommentsProps {
build: Doc<"builds">
}
export function GiscusComments({ build }: GiscusCommentsProps) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
// Compute the data-term using build identifier
const term = getBuildIdentifier(
build.config.version,
build.config.target,
build.buildHash,
build.config.pluginsEnabled
)
// Clear any existing script
containerRef.current.innerHTML = ""
// Create script element
const script = document.createElement("script")
script.src = "https://giscus.app/client.js"
script.setAttribute("data-repo", "MeshEnvy/mesh-forge")
script.setAttribute("data-repo-id", "R_kgDOQa32VQ")
script.setAttribute("data-category", "Builds")
script.setAttribute("data-category-id", "DIC_kwDOQa32Vc4CznuV")
script.setAttribute("data-mapping", "specific")
script.setAttribute("data-term", term)
script.setAttribute("data-strict", "1")
script.setAttribute("data-reactions-enabled", "1")
script.setAttribute("data-emit-metadata", "0")
script.setAttribute("data-input-position", "bottom")
script.setAttribute("data-theme", "dark_tritanopia")
script.setAttribute("data-lang", "en")
script.crossOrigin = "anonymous"
script.async = true
containerRef.current.appendChild(script)
// Cleanup function
return () => {
if (containerRef.current && containerRef.current.contains(script)) {
containerRef.current.removeChild(script)
}
}
}, [build])
return <div ref={containerRef} className="mt-6" />
}

View File

@@ -0,0 +1,74 @@
import { ModuleToggle } from "@/components/ModuleToggle"
import modulesData from "@/convex/modules.json"
import { ChevronDown, ChevronRight } from "lucide-react"
interface ModuleConfigProps {
moduleConfig: Record<string, boolean>
showModuleOverrides: boolean
onToggleShow: () => void
onToggleModule: (id: string, excluded: boolean) => void
onReset: () => void
}
export function ModuleConfig({
moduleConfig,
showModuleOverrides,
onToggleShow,
onToggleModule,
onReset,
}: ModuleConfigProps) {
const moduleCount = Object.keys(moduleConfig).length
return (
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button type="button" onClick={onToggleShow} className="w-full flex items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Core Modules</p>
<p className="text-xs text-slate-400">
{moduleCount === 0
? "Using default modules for this target."
: `${moduleCount} module${moduleCount === 1 ? "" : "s"} excluded.`}
</p>
</div>
{showModuleOverrides ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showModuleOverrides && (
<div className="space-y-2 pr-1">
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
<p className="text-xs text-slate-400 leading-relaxed">
Core Modules are officially maintained modules by Meshtastic. They are selectively included or excluded by
default depending on the target device. You can explicitly exclude modules you know you don't want.
</p>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={onReset}
disabled={moduleCount === 0}
>
Reset overrides
</button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{modulesData.modules.map(module => (
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
isExcluded={moduleConfig[module.id] === true}
onToggle={excluded => onToggleModule(module.id, excluded)}
/>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -48,7 +48,7 @@ export default function Navbar() {
/>
<Unauthenticated>
<Button
onClick={() => signIn("google", { redirectTo: window.location.href })}
onClick={() => signIn("github", { redirectTo: window.location.href })}
className="bg-cyan-600 hover:bg-cyan-700"
>
Sign In

View File

@@ -1,3 +1,4 @@
import { Checkbox } from "@/components/ui/checkbox"
import { Switch } from "@/components/ui/switch"
import { Download, Star, Zap } from "lucide-react"
import { navigate } from "vike/client/router"
@@ -55,6 +56,10 @@ interface PluginCardLinkToggleProps extends PluginCardBaseProps {
onToggle: (enabled: boolean) => void
disabled?: boolean
enabledLabel?: string
diagnostics?: {
checked: boolean
onCheckedChange: (checked: boolean) => void
}
}
type PluginCardProps = PluginCardToggleProps | PluginCardLinkProps | PluginCardLinkToggleProps
@@ -176,109 +181,168 @@ export function PluginCard(props: PluginCardProps) {
{isIncompatible && incompatibleReason && (
<p className="text-xs text-red-400 mt-1 font-medium">{incompatibleReason}</p>
)}
{/* Diagnostics checkbox - only show for link-toggle variant when enabled */}
{isLinkToggle && props.isEnabled && props.diagnostics && (
<div className="mt-2">
<label className="flex items-start gap-2 cursor-pointer group" htmlFor={`${id}-diagnostics`}>
<Checkbox
id={`${id}-diagnostics`}
checked={props.diagnostics.checked}
onCheckedChange={props.diagnostics.onCheckedChange}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-slate-300 group-hover:text-white transition-colors">
Include Diagnostics
</div>
<div className="text-xs text-slate-500 mt-0.5">Enable diagnostic logging for this plugin</div>
</div>
</label>
</div>
)}
</div>
</div>
{/* Metadata row */}
<div className="flex items-center gap-3 flex-wrap text-xs text-slate-400">
{version && <span className="text-slate-500">v{version}</span>}
{isLinkToggle && flashCount !== undefined && (
<div className="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Download"
>
<path d="m14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm4 18V9h-5V4H6v16zm-6-1l-4-4h2.5v-3h3v3H16z" />
</svg>
<span>{flashCount}</span>
</div>
)}
{isLink && downloads !== undefined && (
<div className="flex items-center gap-1">
<Download className="w-3.5 h-3.5" />
<span>{downloads.toLocaleString()}</span>
</div>
)}
{homepage && homepage !== repo && (isLink || isLinkToggle) && (
<a
href={homepage}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="hover:opacity-80 transition-opacity"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Homepage"
>
<path
{/* Footer with metadata and toggle */}
<div className="flex items-center justify-between gap-3 mt-auto pt-2 border-t border-slate-700/50">
{/* Metadata row */}
<div className="flex items-center gap-3 flex-wrap text-xs text-slate-400">
{version && <span className="text-slate-500">v{version}</span>}
{isLinkToggle && flashCount !== undefined && (
<div className="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
/>
</svg>
</a>
)}
{starsBadgeUrl && repo && (
<a
href={repo}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="hover:opacity-80 transition-opacity"
>
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
</a>
)}
</div>
{/* Build Now button - absolutely positioned in lower right */}
{isLink && (
<div className="absolute bottom-4 right-4 z-10">
role="img"
aria-label="Download"
>
<path d="m14 2l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm4 18V9h-5V4H6v16zm-6-1l-4-4h2.5v-3h3v3H16z" />
</svg>
<span>{flashCount}</span>
</div>
)}
{isLink && downloads !== undefined && (
<div className="flex items-center gap-1">
<Download className="w-3.5 h-3.5" />
<span>{downloads.toLocaleString()}</span>
</div>
)}
{homepage &&
homepage !== repo &&
(isLink || isLinkToggle) &&
(isLink ? (
<button
type="button"
onClick={e => {
e.preventDefault()
e.stopPropagation()
window.open(homepage, "_blank", "noopener,noreferrer")
}}
className="hover:opacity-80 transition-opacity"
aria-label="Homepage"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Homepage"
>
<path
fill="currentColor"
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
/>
</svg>
</button>
) : (
<a
href={homepage}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="hover:opacity-80 transition-opacity"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
className="text-slate-400"
fill="currentColor"
role="img"
aria-label="Homepage"
>
<path
fill="currentColor"
d="m12 3l11 8.25l-1.2 1.6L20 11.5V21H4v-9.5l-1.8 1.35l-1.2-1.6zm-4.65 9.05q0 1.325 1.425 2.825T12 18q1.8-1.625 3.225-3.125t1.425-2.825q0-1.1-.75-1.825T14.1 9.5q-.65 0-1.188.263T12 10.45q-.375-.425-.937-.687T9.9 9.5q-1.05 0-1.8.725t-.75 1.825"
/>
</svg>
</a>
))}
{starsBadgeUrl &&
repo &&
(isLink ? (
<button
type="button"
onClick={e => {
e.preventDefault()
e.stopPropagation()
window.open(repo, "_blank", "noopener,noreferrer")
}}
className="hover:opacity-80 transition-opacity"
aria-label="GitHub repository"
>
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
</button>
) : (
<a
href={repo}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="hover:opacity-80 transition-opacity"
>
<img src={starsBadgeUrl} alt="GitHub stars" className="h-4" />
</a>
))}
</div>
{/* Toggle switch or Build Now button */}
{isLinkToggle ? (
<Switch
checked={props.isEnabled}
onCheckedChange={props.onToggle}
disabled={props.disabled}
labelLeft="Skip"
labelRight={props.enabledLabel || "Add"}
className={props.isEnabled ? "bg-green-600" : "bg-slate-600"}
/>
) : isLink ? (
<button
onClick={e => {
e.preventDefault()
e.stopPropagation()
navigate(`/builds/new?plugin=${id}`)
navigate(`/builds?plugin=${id}`)
}}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer"
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 rounded hover:bg-cyan-400/20 transition-colors cursor-pointer shrink-0"
>
<Zap className="w-3 h-3" />
Build Now
</button>
</div>
)}
{/* Toggle switch - absolutely positioned in lower right */}
{isLinkToggle && (
<div className="absolute bottom-4 right-4 z-10">
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={props.isEnabled}
onCheckedChange={props.onToggle}
disabled={props.disabled}
labelLeft="Skip"
labelRight={props.enabledLabel || "Add"}
className={props.isEnabled ? "bg-green-600" : "bg-slate-600"}
/>
</div>
</div>
)}
) : null}
</div>
</>
)}
</>
)
const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col gap-3"} p-4 rounded-lg border-2 transition-colors h-full ${
const baseClassName = `relative flex ${isToggle ? "items-start gap-4" : "flex-col"} p-4 rounded-lg border-2 transition-colors h-full ${
isIncompatible
? "border-slate-800 bg-slate-900/30 opacity-60 cursor-not-allowed"
: prominent
@@ -287,7 +351,7 @@ export function PluginCard(props: PluginCardProps) {
} ${isLink ? "group" : ""}`
if (isLink) {
const href = props.href || `/builds/new?plugin=${id}`
const href = props.href || `/builds?plugin=${id}`
return (
<a href={href} className={baseClassName}>
{cardContent}

176
components/PluginConfig.tsx Normal file
View File

@@ -0,0 +1,176 @@
import { PluginCard } from "@/components/PluginCard"
import {
getDependedPlugins,
getImplicitDependencies,
isPluginCompatibleWithTarget,
isRequiredByOther,
} from "@/lib/utils"
import registryData from "@/public/registry.json"
import { ChevronDown, ChevronRight } from "lucide-react"
interface PluginConfigProps {
pluginConfig: Record<string, boolean>
pluginOptionsConfig: Record<string, Record<string, boolean>>
selectedTarget: string
pluginParam?: string
pluginFlashCounts: Record<string, number>
showPlugins: boolean
onToggleShow: () => void
onTogglePlugin: (id: string, enabled: boolean) => void
onTogglePluginOption: (pluginId: string, optionKey: string, enabled: boolean) => void
onReset: () => void
}
export function PluginConfig({
pluginConfig,
pluginOptionsConfig,
selectedTarget,
pluginParam,
pluginFlashCounts,
showPlugins,
onToggleShow,
onTogglePlugin,
onTogglePluginOption,
onReset,
}: PluginConfigProps) {
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
// Get explicitly selected plugins (user-selected)
const explicitPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Compute implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Compute all enabled plugins (explicit + implicit)
const allEnabledPlugins = getDependedPlugins(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
return (
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button type="button" onClick={onToggleShow} className="w-full flex items-center justify-between text-left">
<div>
<p className="text-sm font-medium">Plugins</p>
<p className="text-xs text-slate-400">
{pluginCount === 0
? "No plugins enabled."
: `${pluginCount} plugin${pluginCount === 1 ? "" : "s"} enabled.`}
</p>
</div>
{showPlugins ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showPlugins && (
<div className="space-y-2 pr-1">
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
<p className="text-xs text-slate-400 leading-relaxed">
Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at your
own risk.
</p>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={onReset}
disabled={pluginCount === 0}
>
Reset plugins
</button>
</div>
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
{Object.entries(registryData)
.sort(([, pluginA], [, pluginB]) => {
// Featured plugins first
const featuredA = (pluginA as { featured?: boolean }).featured ?? false
const featuredB = (pluginB as { featured?: boolean }).featured ?? false
if (featuredA !== featuredB) {
return featuredA ? -1 : 1
}
// Then alphabetical by name
return (pluginA as { name: string }).name.localeCompare((pluginB as { name: string }).name)
})
.map(([slug, plugin]) => {
// Check if plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
slug,
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Plugin is implicit if it's either:
// 1. Not explicitly selected but is a dependency, OR
// 2. Explicitly selected but required by another explicitly selected plugin
const isImplicit = implicitDeps.has(slug) || (explicitPlugins.includes(slug) && isRequired)
// Check plugin compatibility with selected target
const pluginIncludes = (plugin as { includes?: string[] }).includes
const pluginExcludes = (plugin as { excludes?: string[] }).excludes
// Legacy support: check for old "architectures" field
const legacyArchitectures = (plugin as { architectures?: string[] }).architectures
const hasCompatibilityConstraints =
(pluginIncludes && pluginIncludes.length > 0) ||
(pluginExcludes && pluginExcludes.length > 0) ||
(legacyArchitectures && legacyArchitectures.length > 0)
const isCompatible =
hasCompatibilityConstraints && selectedTarget
? isPluginCompatibleWithTarget(
pluginIncludes || legacyArchitectures,
pluginExcludes,
selectedTarget
)
: true // If no constraints or no target selected, assume compatible
// Mark as incompatible if plugin has compatibility constraints and target is not compatible
const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget
// Check if this is the preselected plugin from URL
const isPreselected = pluginParam === slug
const pluginRegistry = plugin as {
featured?: boolean
}
const isPluginEnabled = allEnabledPlugins.includes(slug)
const pluginOptions = pluginOptionsConfig[slug] ?? {}
return (
<PluginCard
key={`${slug}-${selectedTarget}`}
variant="link-toggle"
id={slug}
name={plugin.name}
description={plugin.description}
imageUrl={plugin.imageUrl}
isEnabled={isPluginEnabled}
onToggle={enabled => onTogglePlugin(slug, enabled)}
disabled={isImplicit || isIncompatible || isPreselected}
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
featured={pluginRegistry.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage}
version={plugin.version}
repo={plugin.repo}
diagnostics={
isPluginEnabled
? {
checked: pluginOptions.diagnostics ?? false,
onCheckedChange: checked => onTogglePluginOption(slug, "diagnostics", checked === true),
}
: undefined
}
/>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { TARGETS } from "@/constants/targets"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
interface TargetSelectorProps {
activeCategory: string
categories: string[]
groupedTargets: Record<string, TargetGroup[]>
selectedTarget: string
compatibleTargets: Set<string> | null
onCategoryChange: (category: string) => void
onTargetSelect: (targetId: string) => void
}
export function TargetSelector({
activeCategory,
categories,
groupedTargets,
selectedTarget,
compatibleTargets,
onCategoryChange,
onTargetSelect,
}: TargetSelectorProps) {
const targets = activeCategory ? groupedTargets[activeCategory] : []
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{categories.map(category => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => onCategoryChange(category)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive ? "bg-blue-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
}`}
>
{category}
</button>
)
})}
</div>
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
<div className="flex flex-wrap gap-2">
{targets?.map(target => {
const isSelected = selectedTarget === target.id
const normalizedId = target.id.replace(/[-_]/g, "")
const isCompatible =
!compatibleTargets || compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
return (
<button
key={target.id}
type="button"
onClick={() => isCompatible && onTargetSelect(target.id)}
disabled={!isCompatible}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isSelected
? "bg-cyan-600 text-white"
: isCompatible
? "bg-slate-800 text-slate-300 hover:bg-slate-700"
: "bg-slate-800/50 text-slate-500 cursor-not-allowed opacity-50"
}`}
>
{target.name}
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { VERSIONS } from "@/constants/versions"
interface VersionSelectorProps {
selectedVersion: string
onVersionChange: (version: string) => void
}
export function VersionSelector({ selectedVersion, onVersionChange }: VersionSelectorProps) {
return (
<div>
<label htmlFor="build-version" className="block text-sm font-medium mb-2">
Firmware version
</label>
<select
id="build-version"
value={selectedVersion}
onChange={event => onVersionChange(event.target.value)}
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
>
{VERSIONS.map(version => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
</div>
)
}

View File

@@ -1,210 +1,210 @@
{
"betafpv2400txmicro": "esp32",
"betafpv900txnano": "esp32",
"betafpv_2400_tx_micro": "esp32",
"betafpv_900_tx_nano": "esp32",
"chatter2": "esp32",
"9m2ibraprsloratracker": "esp32",
"meshtasticdrdev": "esp32",
"9m2ibr_aprs_lora_tracker": "esp32",
"meshtastic-dr-dev": "esp32",
"hydra": "esp32",
"meshtasticdiyv1": "esp32",
"meshtasticdiyv11": "esp32",
"hackerboxesesp32io": "esp32",
"heltecv1": "esp32",
"heltecv20": "esp32",
"heltecv21": "esp32",
"heltecwirelessbridge": "esp32",
"heltecwslv21": "esp32",
"m5stackcore": "esp32",
"m5stackcoreink": "esp32",
"nanog1": "esp32",
"nanog1explorer": "esp32",
"radiomaster900bandit": "esp32",
"radiomaster900banditmicro": "esp32",
"radiomaster900banditnano": "esp32",
"meshtastic-diy-v1": "esp32",
"meshtastic-diy-v1_1": "esp32",
"hackerboxes-esp32-io": "esp32",
"heltec-v1": "esp32",
"heltec-v2_0": "esp32",
"heltec-v2_1": "esp32",
"heltec-wireless-bridge": "esp32",
"heltec-wsl-v2_1": "esp32",
"m5stack-core": "esp32",
"m5stack-coreink": "esp32",
"nano-g1": "esp32",
"nano-g1-explorer": "esp32",
"radiomaster_900_bandit": "esp32",
"radiomaster_900_bandit_micro": "esp32",
"radiomaster_900_bandit_nano": "esp32",
"rak11200": "esp32",
"stationg1": "esp32",
"station-g1": "esp32",
"tbeam": "esp32",
"tbeamdisplayshield": "tbeam",
"tbeam07": "esp32",
"tlorav1": "esp32",
"tlorav13": "esp32",
"tlorav2": "esp32",
"tlorav2116": "esp32",
"sugarcube": "tlorav2116",
"tlorav2116tcxo": "esp32",
"tlorav2118": "esp32",
"tlorav330tcxo": "esp32",
"tbeam-displayshield": "tbeam",
"tbeam0_7": "esp32",
"tlora-v1": "esp32",
"tlora_v1_3": "esp32",
"tlora-v2": "esp32",
"tlora-v2-1-1_6": "esp32",
"sugarcube": "tlora-v2-1-1_6",
"tlora-v2-1-1_6-tcxo": "esp32",
"tlora-v2-1-1_8": "esp32",
"tlora-v3-3-0-tcxo": "esp32",
"trackerd": "esp32",
"wiphone": "esp32",
"aic3": "esp32c3",
"esp32c3supermini": "esp32c3",
"esp32c3base": "esp32",
"hackerboxesesp32c3oled": "esp32c3",
"heltecht62esp32c3sx1262": "esp32c3",
"heltechru3601": "esp32c3",
"m5stackstampc3": "esp32c3",
"esp32c6base": "esp32",
"m5stackunitc6l": "esp32c6",
"tlorac6": "esp32c6",
"esp32s2base": "esp32",
"nuggets2lora": "esp32s2",
"CDEBYTEEoRaS3": "esp32s3",
"EBYTEESP32S3": "esp32s3",
"thinknodem2": "esp32s3",
"thinknodem5": "esp32s3",
"bpipicowesp32s3": "esp32s3",
"crowpanelesp32s35epaper": "esp32s3",
"crowpanelesp32s34epaper": "esp32s3",
"crowpanelesp32s32epaper": "esp32s3",
"myesp32s3diyeink": "esp32s3",
"myesp32s3diyoled": "esp32s3",
"tenergys3e22": "esp32s3",
"dreamcatcher2206": "esp32s3",
"crowpanelbase": "crowpanel",
"elecrowadv2428tft": "crowpanelsmallesp32s3base",
"elecrowadv35tft": "crowpanelsmallesp32s3base",
"elecrowadv1435070tft": "crowpanellargeesp32s3base",
"ESP32S3Pico": "esp32s3",
"esp32s3base": "esp32",
"hackadaycommunicator": "esp32s3",
"helteccapsulesensorv3": "esp32s3",
"heltecsensorhub": "esp32s3",
"heltecv3": "esp32s3",
"heltecv4base": "esp32s3",
"heltecv4": "heltecv4base",
"heltecv4tft": "heltecv4base",
"heltecvisionmastere213": "esp32s3",
"heltecvisionmastere213inkhud": "esp32s3",
"heltecvisionmastere290": "esp32s3",
"heltecvisionmastere290inkhud": "esp32s3",
"heltecvisionmastert190": "esp32s3",
"heltecwirelesspaper": "esp32s3",
"heltecwirelesspaperinkhud": "esp32s3",
"heltecwirelesspaperv10": "esp32s3",
"heltecwirelesstracker": "esp32s3",
"heltecwirelesstrackerV10": "esp32s3",
"heltecwirelesstrackerv2": "esp32s3",
"heltecwslv3": "esp32s3",
"ai-c3": "esp32c3",
"esp32c3_super_mini": "esp32c3",
"esp32c3_base": "esp32",
"hackerboxes-esp32c3-oled": "esp32c3",
"heltec-ht62-esp32c3-sx1262": "esp32c3",
"heltec-hru-3601": "esp32c3",
"m5stack-stamp-c3": "esp32c3",
"esp32c6_base": "esp32",
"m5stack-unitc6l": "esp32c6",
"tlora-c6": "esp32c6",
"esp32s2_base": "esp32",
"nugget-s2-lora": "esp32s2",
"CDEBYTE_EoRa-S3": "esp32s3",
"EBYTE_ESP32-S3": "esp32s3",
"thinknode_m2": "esp32s3",
"thinknode_m5": "esp32s3",
"bpi_picow_esp32_s3": "esp32s3",
"crowpanel-esp32s3-5-epaper": "esp32s3",
"crowpanel-esp32s3-4-epaper": "esp32s3",
"crowpanel-esp32s3-2-epaper": "esp32s3",
"my-esp32s3-diy-eink": "esp32s3",
"my-esp32s3-diy-oled": "esp32s3",
"t-energy-s3_e22": "esp32s3",
"dreamcatcher-2206": "esp32s3",
"crowpanel_base": "crowpanel",
"elecrow-adv-24-28-tft": "crowpanel_small_esp32s3_base",
"elecrow-adv-35-tft": "crowpanel_small_esp32s3_base",
"elecrow-adv1-43-50-70-tft": "crowpanel_large_esp32s3_base",
"ESP32-S3-Pico": "esp32s3",
"esp32s3_base": "esp32",
"hackaday-communicator": "esp32s3",
"heltec_capsule_sensor_v3": "esp32s3",
"heltec_sensor_hub": "esp32s3",
"heltec-v3": "esp32s3",
"heltec_v4_base": "esp32s3",
"heltec-v4": "heltec_v4_base",
"heltec-v4-tft": "heltec_v4_base",
"heltec-vision-master-e213": "esp32s3",
"heltec-vision-master-e213-inkhud": "esp32s3",
"heltec-vision-master-e290": "esp32s3",
"heltec-vision-master-e290-inkhud": "esp32s3",
"heltec-vision-master-t190": "esp32s3",
"heltec-wireless-paper": "esp32s3",
"heltec-wireless-paper-inkhud": "esp32s3",
"heltec-wireless-paper-v1_0": "esp32s3",
"heltec-wireless-tracker": "esp32s3",
"heltec-wireless-tracker-V1-0": "esp32s3",
"heltec-wireless-tracker-v2": "esp32s3",
"heltec-wsl-v3": "esp32s3",
"icarus": "esp32s3",
"link32s3v1": "esp32s3",
"m5stackcores3": "esp32s3",
"meshtabbase": "esp32s3",
"meshtab32TNresistive": "meshtabbase",
"meshtab32IPSresistive": "meshtabbase",
"meshtab35IPSresistive": "meshtabbase",
"meshtab35TNresistive": "meshtabbase",
"meshtab32IPScapacitive": "meshtabbase",
"meshtab35IPScapacitive": "meshtabbase",
"meshtab40IPScapacitive": "meshtabbase",
"nibbleesp32": "esp32s3",
"nuggets3lora": "esp32s3",
"picomputers3": "esp32s3",
"picomputers3tft": "picomputers3",
"link32-s3-v1": "esp32s3",
"m5stack-cores3": "esp32s3",
"mesh_tab_base": "esp32s3",
"mesh-tab-3-2-TN-resistive": "mesh_tab_base",
"mesh-tab-3-2-IPS-resistive": "mesh_tab_base",
"mesh-tab-3-5-IPS-resistive": "mesh_tab_base",
"mesh-tab-3-5-TN-resistive": "mesh_tab_base",
"mesh-tab-3-2-IPS-capacitive": "mesh_tab_base",
"mesh-tab-3-5-IPS-capacitive": "mesh_tab_base",
"mesh-tab-4-0-IPS-capacitive": "mesh_tab_base",
"nibble-esp32": "esp32s3",
"nugget-s3-lora": "esp32s3",
"picomputer-s3": "esp32s3",
"picomputer-s3-tft": "picomputer-s3",
"rak3312": "esp32s3",
"rakwismeshtapv2tft": "rakwismeshtaps3",
"seeedsensecapindicator": "esp32s3",
"seeedsensecapindicatortft": "seeedsensecapindicator",
"seeedxiaos3": "esp32s3",
"stationg2": "esp32s3",
"tdeck": "esp32s3",
"tdecktft": "tdeck",
"tdeckpro": "esp32s3",
"tethelite": "esp32s3",
"twatchs3": "esp32s3",
"tbeams3core": "esp32s3",
"tlorapager": "esp32s3",
"tlorapagertft": "tlorapager",
"tlorat3s3epaper": "esp32s3",
"tlorat3s3epaperinkhud": "esp32s3",
"tlorat3s3v1": "esp32s3",
"rak_wismesh_tap_v2-tft": "rak_wismeshtap_s3",
"seeed-sensecap-indicator": "esp32s3",
"seeed-sensecap-indicator-tft": "seeed-sensecap-indicator",
"seeed-xiao-s3": "esp32s3",
"station-g2": "esp32s3",
"t-deck": "esp32s3",
"t-deck-tft": "t-deck",
"t-deck-pro": "esp32s3",
"t-eth-elite": "esp32s3",
"t-watch-s3": "esp32s3",
"tbeam-s3-core": "esp32s3",
"tlora-pager": "esp32s3",
"tlora-pager-tft": "tlora-pager",
"tlora-t3s3-epaper": "esp32s3",
"tlora-t3s3-epaper-inkhud": "esp32s3",
"tlora-t3s3-v1": "esp32s3",
"tracksenger": "esp32s3",
"tracksengerlcd": "esp32s3",
"tracksengeroled": "esp32s3",
"tracksenger-lcd": "esp32s3",
"tracksenger-oled": "esp32s3",
"unphone": "esp32s3",
"unphonetft": "unphone",
"unphone-tft": "unphone",
"coverage": "native",
"buildroot": "portduino",
"pca10059diyeink": "nrf52840",
"thinknodem1": "nrf52840",
"thinknodem1inkhud": "nrf52840",
"thinknodem3": "nrf52840",
"thinknodem6": "nrf52840",
"ME25LS014Y10TD": "nrf52840",
"ME25LS014Y10TDeink": "nrf52840",
"pca10059_diy_eink": "nrf52840",
"thinknode_m1": "nrf52840",
"thinknode_m1-inkhud": "nrf52840",
"thinknode_m3": "nrf52840",
"thinknode_m6": "nrf52840",
"ME25LS01-4Y10TD": "nrf52840",
"ME25LS01-4Y10TD_e-ink": "nrf52840",
"ms24sf1": "nrf52840",
"makerpythonnrf52840sx1280eink": "nrf52840",
"makerpythonnrf52840sx1280oled": "nrf52840",
"TWCmeshv4": "nrf52840",
"makerpython_nrf52840_sx1280_eink": "nrf52840",
"makerpython_nrf52840_sx1280_oled": "nrf52840",
"TWC_mesh_v4": "nrf52840",
"canaryone": "nrf52840",
"WashTastic": "nrf52840",
"nrf52promicrodiytcxo": "nrf52840",
"nrf52promicrodiyinkhud": "nrf52840",
"seeedxiaonrf52840wiosx1262": "nrf52840",
"seeedxiaonrf52840e22900m30s": "seeedxiaonrf52840kit",
"seeedxiaonrf52840e22900m33s": "seeedxiaonrf52840kit",
"xiaoble": "seeedxiaonrf52840kit",
"featherdiy": "nrf52840",
"gat562meshtrialtracker": "nrf52840",
"heltecmeshnodet114": "nrf52840",
"heltecmeshnodet114inkhud": "nrf52840",
"heltecmeshpocket5000": "nrf52840",
"heltecmeshpocket5000inkhud": "nrf52840",
"heltecmeshpocket10000": "nrf52840",
"heltecmeshpocket10000inkhud": "nrf52840",
"heltecmeshsolarbase": "nrf52840",
"heltecmeshsolar": "heltecmeshsolarbase",
"heltecmeshsolareink": "heltecmeshsolarbase",
"heltecmeshsolarinkhud": "heltecmeshsolarbase",
"heltecmeshsolaroled": "heltecmeshsolarbase",
"heltecmeshsolartft": "heltecmeshsolarbase",
"nrf52_promicro_diy_tcxo": "nrf52840",
"nrf52_promicro_diy-inkhud": "nrf52840",
"seeed-xiao-nrf52840-wio-sx1262": "nrf52840",
"seeed_xiao_nrf52840_e22_900m30s": "seeed_xiao_nrf52840_kit",
"seeed_xiao_nrf52840_e22_900m33s": "seeed_xiao_nrf52840_kit",
"xiao_ble": "seeed_xiao_nrf52840_kit",
"feather_diy": "nrf52840",
"gat562_mesh_trial_tracker": "nrf52840",
"heltec-mesh-node-t114": "nrf52840",
"heltec-mesh-node-t114-inkhud": "nrf52840",
"heltec-mesh-pocket-5000": "nrf52840",
"heltec-mesh-pocket-5000-inkhud": "nrf52840",
"heltec-mesh-pocket-10000": "nrf52840",
"heltec-mesh-pocket-10000-inkhud": "nrf52840",
"heltec_mesh_solar_base": "nrf52840",
"heltec-mesh-solar": "heltec_mesh_solar_base",
"heltec-mesh-solar-eink": "heltec_mesh_solar_base",
"heltec-mesh-solar-inkhud": "heltec_mesh_solar_base",
"heltec-mesh-solar-oled": "heltec_mesh_solar_base",
"heltec-mesh-solar-tft": "heltec_mesh_solar_base",
"meshlink": "nrf52840",
"meshlinkeink": "nrf52840",
"meshlink_eink": "nrf52840",
"meshtiny": "nrf52840",
"monteopshw1": "nrf52840",
"muzibase": "nrf52840",
"nanog2ultra": "nrf52840",
"nrf52832base": "nrf52",
"nrf52840base": "nrf52",
"r1neo": "nrf52840",
"monteops_hw1": "nrf52840",
"muzi-base": "nrf52840",
"nano-g2-ultra": "nrf52840",
"nrf52832_base": "nrf52",
"nrf52840_base": "nrf52",
"r1-neo": "nrf52840",
"rak2560": "nrf52840",
"rak34011watt": "nrf52840",
"rak3401-1watt": "nrf52840",
"rak4631": "nrf52840",
"rak4631dbg": "rak4631",
"rak4631eink": "nrf52840",
"rak4631einkonrxtx": "nrf52840",
"rak4631ethgw": "nrf52840",
"rak4631ethgwdbg": "rak4631",
"rak4631nomadstarmeteorpro": "nrf52840",
"rak4631nomadstarmeteorprodbg": "rak4631nomadstarmeteorpro",
"rakwismeshtag": "nrf52840",
"rakwismeshtap": "nrf52840",
"seeedsolarnode": "nrf52840",
"seeedwiotrackerL1": "nrf52840",
"seeedwiotrackerL1eink": "nrf52840",
"seeedwiotrackerL1einkinkhud": "nrf52840",
"seeedxiaonrf52840kit": "nrf52840",
"seeedxiaonrf52840kiti2c": "seeedxiaonrf52840kit",
"techo": "nrf52840",
"techoinkhud": "nrf52840",
"techolite": "nrf52840",
"trackert1000e": "nrf52840",
"wiosdkwm1110": "nrf52840",
"wiot1000s": "nrf52840",
"wiotrackerwm1110": "nrf52840",
"challenger2040lora": "rp2040",
"rak4631_dbg": "rak4631",
"rak4631_eink": "nrf52840",
"rak4631_eink_onrxtx": "nrf52840",
"rak4631_eth_gw": "nrf52840",
"rak4631_eth_gw_dbg": "rak4631",
"rak4631_nomadstar_meteor_pro": "nrf52840",
"rak4631_nomadstar_meteor_pro_dbg": "rak4631_nomadstar_meteor_pro",
"rak_wismeshtag": "nrf52840",
"rak_wismeshtap": "nrf52840",
"seeed_solar_node": "nrf52840",
"seeed_wio_tracker_L1": "nrf52840",
"seeed_wio_tracker_L1_eink": "nrf52840",
"seeed_wio_tracker_L1_eink-inkhud": "nrf52840",
"seeed_xiao_nrf52840_kit": "nrf52840",
"seeed_xiao_nrf52840_kit_i2c": "seeed_xiao_nrf52840_kit",
"t-echo": "nrf52840",
"t-echo-inkhud": "nrf52840",
"t-echo-lite": "nrf52840",
"tracker-t1000-e": "nrf52840",
"wio-sdk-wm1110": "nrf52840",
"wio-t1000-s": "nrf52840",
"wio-tracker-wm1110": "nrf52840",
"challenger_2040_lora": "rp2040",
"catsniffer": "rp2040",
"featherrp2040rfm95": "rp2040",
"nibblerp2040": "rp2040",
"feather_rp2040_rfm95": "rp2040",
"nibble-rp2040": "rp2040",
"rak11310": "rp2040",
"rp2040lora": "rp2040",
"rp2040-lora": "rp2040",
"pico": "rp2040",
"picoslowclock": "rp2040",
"pico_slowclock": "rp2040",
"picow": "rp2040",
"senselorarp2040": "rp2040",
"senselora_rp2040": "rp2040",
"pico2": "rp2350",
"pico2w": "rp2350",
"CDEBYTEE77MBL": "stm32",
"CDEBYTE_E77-MBL": "stm32",
"rak3172": "stm32",
"wioe5": "stm32",
"wio-e5": "stm32",
"esp32c3": "esp32",
"esp32c6": "esp32",
"esp32s2": "esp32",
@@ -217,4 +217,4 @@
"rp2350": null,
"stm32": null,
"portduino": null
}
}

View File

@@ -1,4 +1,5 @@
import hardwareList from "@/vendor/web-flasher/public/data/hardware-list.json"
import architectureHierarchy from "@/constants/architecture-hierarchy.json"
import vendorsData from "@/constants/vendors.json"
export interface TargetMetadata {
name: string
@@ -6,17 +7,43 @@ export interface TargetMetadata {
architecture?: string
}
/**
* Trace a target back to its base architecture
*/
function getBaseArchitecture(target: string): string | null {
const parentMap = architectureHierarchy as Record<string, string | null>
const visited = new Set<string>()
let current: string | null = target
while (current && !visited.has(current)) {
visited.add(current)
if (!current) break
const parent: string | null | undefined = parentMap[current]
if (parent === null) {
return current
}
if (parent === undefined) {
return current
}
current = parent
}
return current || target
}
export const TARGETS: Record<string, TargetMetadata> = {}
// Sort by display name
const sortedHardware = [...hardwareList].sort((a, b) => (a.displayName || "").localeCompare(b.displayName || ""))
sortedHardware.forEach(hw => {
if (hw.platformioTarget) {
TARGETS[hw.platformioTarget] = {
name: hw.displayName || hw.platformioTarget,
category: hw.tags?.[0] || "Other",
architecture: hw.architecture,
// Build TARGETS from vendors.json and architecture-hierarchy.json
for (const [vendor, models] of Object.entries(vendorsData)) {
for (const [modelName, target] of Object.entries(models)) {
const architecture = getBaseArchitecture(target)
TARGETS[target] = {
name: modelName,
category: vendor,
architecture: architecture || undefined,
}
}
})
}

244
constants/vendors.json Normal file
View File

@@ -0,0 +1,244 @@
{
"B&Q": {
"Nano G1": "nano-g1",
"Nano G1 Explorer": "nano-g1-explorer",
"Nano G2 Ultra": "nano-g2-ultra",
"Station G1": "station-g1",
"Station G2": "station-g2"
},
"BetaFPV": {
"2400TX Micro": "betafpv_2400_tx_micro",
"900TX Nano": "betafpv_900_tx_nano"
},
"Canary": {
"One": "canaryone"
},
"CDEByte": {
"EoRa S3": "CDEBYTE_EoRa-S3",
"E77MBL": "CDEBYTE_E77-MBL"
},
"DIY": {
"DR-DEV": "meshtastic-dr-dev",
"Hydra": "hydra",
"V1": "meshtastic-diy-v1",
"V1.1": "meshtastic-diy-v1_1",
"NRF52 Pro-micro DIY": "nrf52_promicro_diy_tcxo",
"NRF52 Pro-micro DIY InkHUD": "nrf52_promicro_diy-inkhud",
"PCA10059 DIY E-Ink": "pca10059_diy_eink"
},
"EByte": {
"ESP32-S3": "EBYTE_ESP32-S3"
},
"Elecrow": {
"Crowpanel Adv 2.4/2.8 TFT": "elecrow-adv-24-28-tft",
"Crowpanel Adv 3.5 TFT": "elecrow-adv-35-tft",
"Crowpanel Adv 4.3/5.0/7.0 TFT": "elecrow-adv1-43-50-70-tft",
"ThinkNode M1": "thinknode_m1",
"ThinkNode M2": "thinknode_m2",
"ThinkNode M3": "thinknode_m3",
"ThinkNode M5": "thinknode_m5",
"ThinkNode M6": "thinknode_m6"
},
"Heltec": {
"V1": "heltec-v1",
"V2.0": "heltec-v2_0",
"V2.1": "heltec-v2_1",
"V3": "heltec-v3",
"V4": "heltec-v4",
"V4 TFT": "heltec-v4-tft",
"HT62": "heltec-ht62-esp32c3-sx1262",
"RU3601": "heltec-hru-3601",
"Wireless Bridge": "heltec-wireless-bridge",
"Wireless Stick Lite V2.1": "heltec-wsl-v2_1",
"Wireless Stick Lite V3": "heltec-wsl-v3",
"Wireless Paper": "heltec-wireless-paper",
"Wireless Paper InkHUD": "heltec-wireless-paper-inkhud",
"Wireless Paper V1.0": "heltec-wireless-paper-v1_0",
"Wireless Tracker": "heltec-wireless-tracker",
"Wireless Tracker V1.0": "heltec-wireless-tracker-V1-0",
"Wireless Tracker V2": "heltec-wireless-tracker-v2",
"Vision Master E213": "heltec-vision-master-e213",
"Vision Master E213 InkHUD": "heltec-vision-master-e213-inkhud",
"Vision Master E290": "heltec-vision-master-e290",
"Vision Master E290 InkHUD": "heltec-vision-master-e290-inkhud",
"Vision Master T190": "heltec-vision-master-t190",
"Capsule Sensor V3": "heltec_capsule_sensor_v3",
"Sensor Hub": "heltec_sensor_hub",
"Mesh Node T114": "heltec-mesh-node-t114",
"Mesh Node T114 InkHUD": "heltec-mesh-node-t114-inkhud",
"MeshPocket 5000": "heltec-mesh-pocket-5000",
"MeshPocket 5000 InkHUD": "heltec-mesh-pocket-5000-inkhud",
"MeshPocket 10000": "heltec-mesh-pocket-10000",
"MeshPocket 10000 InkHUD": "heltec-mesh-pocket-10000-inkhud",
"MeshSolar": "heltec-mesh-solar",
"MeshSolar E-Ink": "heltec-mesh-solar-eink",
"MeshSolar InkHUD": "heltec-mesh-solar-inkhud",
"MeshSolar OLED": "heltec-mesh-solar-oled",
"MeshSolar TFT": "heltec-mesh-solar-tft"
},
"HackerBoxes": {
"ESP32 IO": "hackerboxes-esp32-io",
"ESP32-C3 OLED": "hackerboxes-esp32c3-oled"
},
"LilyGo": {
"T-Beam": "tbeam",
"T-Beam Display Shield": "tbeam-displayshield",
"T-Beam V0.7": "tbeam0_7",
"T-Beam S3 Core": "tbeam-s3-core",
"T-Deck": "t-deck",
"T-Deck TFT": "t-deck-tft",
"T-Deck Pro": "t-deck-pro",
"T-Echo": "t-echo",
"T-Echo InkHUD": "t-echo-inkhud",
"T-Echo Lite": "t-echo-lite",
"T-LoRa V1": "tlora-v1",
"T-LoRa V1.3": "tlora_v1_3",
"T-LoRa V2": "tlora-v2",
"T-LoRa V2.1-1.6": "tlora-v2-1-1_6",
"T-LoRa V2.1-1.6 TCXO": "tlora-v2-1-1_6-tcxo",
"T-LoRa V2.1-1.8": "tlora-v2-1-1_8",
"T-LoRa V3.3.0 TCXO": "tlora-v3-3-0-tcxo",
"T-LoRa C6": "tlora-c6",
"T-LoRa Pager": "tlora-pager",
"T-LoRa Pager TFT": "tlora-pager-tft",
"T-LoRa T3-S3": "tlora-t3s3-v1",
"T-LoRa T3-S3 E-Paper": "tlora-t3s3-epaper",
"T-LoRa T3-S3 E-Paper InkHUD": "tlora-t3s3-epaper-inkhud",
"T-Watch S3": "t-watch-s3",
"Sugar Cube": "sugarcube"
},
"M5Stack": {
"Core": "m5stack-core",
"Core Ink": "m5stack-coreink",
"Core S3": "m5stack-cores3",
"Stamp C3": "m5stack-stamp-c3",
"Unit C6L": "m5stack-unitc6l"
},
"MakerPython": {
"NRF52840 SX1280 E-Ink": "makerpython_nrf52840_sx1280_eink",
"NRF52840 SX1280 OLED": "makerpython_nrf52840_sx1280_oled"
},
"muzi": {
"R1 Neo": "r1-neo"
},
"NomadStar": {
"Meteor Pro": "rak4631_nomadstar_meteor_pro",
"Meteor Pro Debug": "rak4631_nomadstar_meteor_pro_dbg"
},
"RAK": {
"WisBlock 11200": "rak11200",
"WisBlock 11310": "rak11310",
"WisBlock 4631": "rak4631",
"WisBlock 4631 Debug": "rak4631_dbg",
"WisBlock 4631 E-Ink": "rak4631_eink",
"WisBlock 4631 E-Ink on RX/TX": "rak4631_eink_onrxtx",
"WisBlock 4631 ETH Gateway": "rak4631_eth_gw",
"WisBlock 4631 ETH Gateway Debug": "rak4631_eth_gw_dbg",
"WisBlock 3312": "rak3312",
"WisBlock 3172": "rak3172",
"WisBlock 2560": "rak2560",
"WisBlock 3401 1Watt": "rak3401-1watt",
"WisMesh Tag": "rak_wismeshtag",
"WisMesh Tap": "rak_wismeshtap",
"WisMesh Tap V2 TFT": "rak_wismesh_tap_v2-tft"
},
"RadioMaster": {
"900 Bandit": "radiomaster_900_bandit",
"900 Bandit Micro": "radiomaster_900_bandit_micro",
"900 Bandit Nano": "radiomaster_900_bandit_nano"
},
"RPi": {
"Pico": "pico",
"Pico Slow Clock": "pico_slowclock",
"Pico W": "picow",
"Pico 2": "pico2",
"Pico 2W": "pico2w"
},
"Seeed": {
"Xiao ESP32-S3": "seeed-xiao-s3",
"Xiao NRF52840 Kit": "seeed_xiao_nrf52840_kit",
"Xiao NRF52840 Kit I2C": "seeed_xiao_nrf52840_kit_i2c",
"Xiao NRF52840 WIO SX1262": "seeed-xiao-nrf52840-wio-sx1262",
"Xiao NRF52840 E22900M30S": "seeed_xiao_nrf52840_e22_900m30s",
"Xiao NRF52840 E22900M33S": "seeed_xiao_nrf52840_e22_900m33s",
"Xiao BLE": "xiao_ble",
"Wio Tracker L1": "seeed_wio_tracker_L1",
"Wio Tracker L1 E-Ink": "seeed_wio_tracker_L1_eink",
"Wio Tracker L1 E-Ink InkHUD": "seeed_wio_tracker_L1_eink-inkhud",
"Wio Tracker WM1110": "wio-tracker-wm1110",
"Wio SDK WM1110": "wio-sdk-wm1110",
"Wio T1000S": "wio-t1000-s",
"SenseCAP Indicator": "seeed-sensecap-indicator",
"SenseCAP Indicator TFT": "seeed-sensecap-indicator-tft",
"Solar Node": "seeed_solar_node"
},
"Waveshare": {
"RP2040 LoRa": "rp2040-lora"
},
"Other": {
"Chatter2": "chatter2",
"9M2 IBRA PRS LoRa Tracker": "9m2ibr_aprs_lora_tracker",
"AIC3": "ai-c3",
"ESP32-C3": "esp32c3",
"ESP32-C3 Super Mini": "esp32c3_super_mini",
"ESP32-C6": "esp32c6",
"ESP32-S2": "esp32s2",
"ESP32-S3": "esp32s3",
"ESP32-S3 Pico": "ESP32-S3-Pico",
"Crowpanel ESP32-S3 2 E-Paper": "crowpanel-esp32s3-2-epaper",
"Crowpanel ESP32-S3 4 E-Paper": "crowpanel-esp32s3-4-epaper",
"Crowpanel ESP32-S3 5 E-Paper": "crowpanel-esp32s3-5-epaper",
"BPi Pico W ESP32-S3": "bpi_picow_esp32_s3",
"My ESP32-S3 DIY E-Ink": "my-esp32s3-diy-eink",
"My ESP32-S3 DIY OLED": "my-esp32s3-diy-oled",
"T-Energy S3 E22": "t-energy-s3_e22",
"Dreamcatcher 2206": "dreamcatcher-2206",
"Hackaday Communicator": "hackaday-communicator",
"Icarus": "icarus",
"Link32S3 V1": "link32-s3-v1",
"MeshTab 32 TN Resistive": "mesh-tab-3-2-TN-resistive",
"MeshTab 32 IPS Resistive": "mesh-tab-3-2-IPS-resistive",
"MeshTab 35 IPS Resistive": "mesh-tab-3-5-IPS-resistive",
"MeshTab 35 TN Resistive": "mesh-tab-3-5-TN-resistive",
"MeshTab 32 IPS Capacitive": "mesh-tab-3-2-IPS-capacitive",
"MeshTab 35 IPS Capacitive": "mesh-tab-3-5-IPS-capacitive",
"MeshTab 40 IPS Capacitive": "mesh-tab-4-0-IPS-capacitive",
"Nibble ESP32": "nibble-esp32",
"Nugget S2 LoRa": "nugget-s2-lora",
"Nugget S3 LoRa": "nugget-s3-lora",
"Pi Computer S3": "picomputer-s3",
"Pi Computer S3 TFT": "picomputer-s3-tft",
"T-Echo Lite": "t-eth-elite",
"Tracksenger": "tracksenger",
"Tracksenger LCD": "tracksenger-lcd",
"Tracksenger OLED": "tracksenger-oled",
"unPhone": "unphone",
"unPhone TFT": "unphone-tft",
"NRF52832": "nrf52832",
"NRF52840": "nrf52840",
"ThinkNode M1 InkHUD": "thinknode_m1-inkhud",
"ME25LS014Y10TD": "ME25LS01-4Y10TD",
"ME25LS014Y10TD E-Ink": "ME25LS01-4Y10TD_e-ink",
"MS24SF1": "ms24sf1",
"TWC Mesh V4": "TWC_mesh_v4",
"WashTastic": "WashTastic",
"Feather DIY": "feather_diy",
"Feather RP2040 RFM95": "feather_rp2040_rfm95",
"GAT562 Mesh Trial Tracker": "gat562_mesh_trial_tracker",
"MeshLink": "meshlink",
"MeshLink E-Ink": "meshlink_eink",
"MeshTiny": "meshtiny",
"MonteOps HW1": "monteops_hw1",
"R1 Neo": "r1-neo",
"Tracker T1000E": "tracker-t1000-e",
"Tracker D": "trackerd",
"WiPhone": "wiphone",
"Challenger 2040 LoRa": "challenger_2040_lora",
"Cat Sniffer": "catsniffer",
"Nibbler RP2040": "nibble-rp2040",
"SenseLoRa RP2040": "senselora_rp2040",
"Wio E5": "wio-e5",
"Coverage": "coverage",
"Buildroot": "buildroot"
}
}

View File

@@ -15,6 +15,7 @@ import type * as builds from "../builds.js";
import type * as helpers from "../helpers.js";
import type * as http from "../http.js";
import type * as lib_filename from "../lib/filename.js";
import type * as lib_flags from "../lib/flags.js";
import type * as lib_r2 from "../lib/r2.js";
import type * as plugins from "../plugins.js";
import type * as profiles from "../profiles.js";
@@ -33,6 +34,7 @@ declare const fullApi: ApiFromModules<{
helpers: typeof helpers;
http: typeof http;
"lib/filename": typeof lib_filename;
"lib/flags": typeof lib_flags;
"lib/r2": typeof lib_r2;
plugins: typeof plugins;
profiles: typeof profiles;

View File

@@ -1,6 +1,6 @@
import Google from "@auth/core/providers/google"
import GitHub from "@auth/core/providers/github"
import { convexAuth } from "@convex-dev/auth/server"
export const { auth, signIn, signOut, store } = convexAuth({
providers: [Google],
providers: [GitHub],
})

View File

@@ -3,15 +3,11 @@ import { v } from "convex/values"
import { api, internal } from "./_generated/api"
import type { Doc, Id } from "./_generated/dataModel"
import { internalMutation, mutation, query } from "./_generated/server"
import { getArtifactFilenameBase } from "./lib/filename"
import { ArtifactType, getArtifactFilenameBase } from "./lib/filename"
import { computeFlagsFromConfig } from "./lib/flags"
import { generateSignedDownloadUrl } from "./lib/r2"
import { buildFields } from "./schema"
export enum ArtifactType {
Firmware = "firmware",
Source = "source",
}
type BuildUpdateData = {
status: string
completedAt?: number
@@ -36,18 +32,8 @@ export const getByHash = query({
},
})
/**
* Computes flags string from build config.
* Only excludes modules explicitly marked as excluded (config[id] === true).
*/
export function computeFlagsFromConfig(config: Doc<"builds">["config"]): string {
// Sort modules to ensure consistent order
return Object.keys(config.modulesExcluded)
.sort()
.filter(module => config.modulesExcluded[module])
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
.join(" ")
}
// Re-export for backward compatibility
export { computeFlagsFromConfig } from "./lib/flags"
/**
* Encodes a byte array to base62 string.
@@ -82,16 +68,39 @@ async function computeBuildHashInternal(
version: string,
target: string,
flags: string,
plugins: string[]
plugins: string[],
pluginConfig?: Record<string, Record<string, boolean>>
): Promise<string> {
// Input is now the exact parameters used for the build
// Sort plugins array for consistent hashing
const sortedPlugins = [...plugins].sort()
// Sort plugin config for consistent hashing
const sortedPluginConfig = pluginConfig
? Object.keys(pluginConfig)
.sort()
.reduce(
(acc, pluginSlug) => {
const sortedOptions = Object.keys(pluginConfig[pluginSlug])
.sort()
.reduce(
(opts, optKey) => {
opts[optKey] = pluginConfig[pluginSlug][optKey]
return opts
},
{} as Record<string, boolean>
)
acc[pluginSlug] = sortedOptions
return acc
},
{} as Record<string, Record<string, boolean>>
)
: undefined
const input = JSON.stringify({
version,
target,
flags,
plugins: sortedPlugins,
pluginConfig: sortedPluginConfig,
})
// Use Web Crypto API for SHA-256 hashing
@@ -108,10 +117,13 @@ async function computeBuildHashInternal(
* Computes buildHash from build config.
* This is the single source of truth for build hash computation.
*/
export async function computeBuildHash(config: Doc<"builds">["config"]): Promise<{ hash: string; flags: string }> {
const flags = computeFlagsFromConfig(config)
export async function computeBuildHash(
config: Doc<"builds">["config"],
registryData?: Record<string, { configOptions?: Record<string, { define: string }> }>
): Promise<{ hash: string; flags: string }> {
const flags = computeFlagsFromConfig(config, registryData)
const plugins = config.pluginsEnabled ?? []
const hash = await computeBuildHashInternal(config.version, config.target, flags, plugins)
const hash = await computeBuildHashInternal(config.version, config.target, flags, plugins, config.pluginConfigs)
return { hash, flags }
}
@@ -190,6 +202,8 @@ export const ensureBuildFromConfig = mutation({
version: v.string(),
modulesExcluded: v.optional(v.record(v.string(), v.boolean())),
pluginsEnabled: v.optional(v.array(v.string())),
pluginConfigs: v.optional(v.record(v.string(), v.record(v.string(), v.boolean()))),
registryData: v.optional(v.any()),
profileName: v.optional(v.string()),
profileDescription: v.optional(v.string()),
},
@@ -200,10 +214,15 @@ export const ensureBuildFromConfig = mutation({
modulesExcluded: args.modulesExcluded ?? {},
target: args.target,
pluginsEnabled: args.pluginsEnabled,
pluginConfigs: args.pluginConfigs,
}
// Compute build hash (single source of truth)
const { hash: buildHash, flags } = await computeBuildHash(config)
// Registry data is optional - diagnostics works for all plugins without registry lookup
const registryData = args.registryData as
| Record<string, { configOptions?: Record<string, { define: string }> }>
| undefined
const { hash: buildHash, flags } = await computeBuildHash(config, registryData)
const existingBuild = await ctx.db
.query("builds")

View File

@@ -1,3 +1,12 @@
/**
* Artifact type enumeration.
* Safe to import in client-side code.
*/
export enum ArtifactType {
Firmware = "firmware",
Source = "source",
}
/**
* Generates the artifact filename base (without extension) matching the download filename format.
* Format: meshtastic-{version}-{target}-{last4hash}-{jobId}-{artifactType}
@@ -27,3 +36,32 @@ export function getArtifactFilenameBase(
return `${os}-${version}-${target}-${last4Hash}-${githubRunId}-${artifactType}`
}
/**
* Generates a build identifier for use in external systems (e.g., Giscus discussions).
* Format: {target}-{version}-{last4hash} or {target}-{version}-{last4hash}-{plugins}
* This is a simpler identifier without OS prefix, job ID, or artifact type.
*
* @param version - The firmware version
* @param target - The target board name
* @param buildHash - The build hash (used to get last 4 characters)
* @param pluginsEnabled - Optional array of enabled plugin slugs
* @returns The build identifier (e.g., "tbeam-v2.7.16-a1b2" or "tbeam-v2.7.16-a1b2-plugin1+plugin2")
*/
export function getBuildIdentifier(
version: string,
target: string,
buildHash: string,
pluginsEnabled?: string[]
): string {
const last4Hash = buildHash.slice(-4)
let identifier = `${target}-${version}-${last4Hash}`
if (pluginsEnabled && pluginsEnabled.length > 0) {
const sortedPlugins = [...pluginsEnabled].sort()
const pluginsStr = sortedPlugins.join("+")
identifier = `${identifier}-${pluginsStr}`
}
return identifier
}

52
convex/lib/flags.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { Doc } from "../_generated/dataModel"
/**
* Computes flags string from build config.
* Only excludes modules explicitly marked as excluded (config[id] === true).
* Also includes plugin config options (e.g., diagnostics).
*
* @param config - Build configuration with modulesExcluded and pluginConfigs
* @param registryData - Optional registry data for custom config options (not needed for diagnostics)
*/
export function computeFlagsFromConfig(
config: Doc<"builds">["config"],
registryData?: Record<string, { configOptions?: Record<string, { define: string }> }>
): string {
const flags: string[] = []
// Sort modules to ensure consistent order
const moduleFlags = Object.keys(config.modulesExcluded)
.sort()
.filter(module => config.modulesExcluded[module])
.map((moduleExcludedName: string) => `-D${moduleExcludedName}=1`)
flags.push(...moduleFlags)
// Add plugin config options (diagnostics is available for all plugins)
if (config.pluginConfigs) {
for (const [pluginSlug, pluginOptions] of Object.entries(config.pluginConfigs)) {
// Handle diagnostics option (available for all plugins)
if (pluginOptions.diagnostics) {
// Convert plugin slug to uppercase define name (e.g., "lofs" -> "LOFS_PLUGIN_DIAGNOSTICS")
const defineName = `${pluginSlug.toUpperCase().replace(/-/g, "_")}_PLUGIN_DIAGNOSTICS`
flags.push(`-D${defineName}`)
}
// Handle other custom config options from registry (if any)
if (registryData) {
const plugin = registryData[pluginSlug]
if (plugin?.configOptions) {
for (const [optionKey, enabled] of Object.entries(pluginOptions)) {
if (optionKey !== "diagnostics" && enabled) {
const option = plugin.configOptions[optionKey]
if (option?.define) {
flags.push(`-D${option.define}`)
}
}
}
}
}
}
}
return flags.join(" ")
}

View File

@@ -7,6 +7,7 @@ export const buildConfigFields = {
modulesExcluded: v.record(v.string(), v.boolean()),
target: v.string(),
pluginsEnabled: v.optional(v.array(v.string())),
pluginConfigs: v.optional(v.record(v.string(), v.record(v.string(), v.boolean()))),
}
export const profileFields = {

View File

@@ -0,0 +1,74 @@
import { TARGETS } from "@/constants/targets"
import { getTargetsCompatibleWithIncludes } from "@/lib/utils"
import registryData from "@/public/registry.json"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || "Other"
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
},
{} as Record<string, TargetGroup[]>
)
export function usePluginCompatibility(enabledPlugins: string[], preselectedPlugin?: { includes?: string[] } | null) {
// Start with preselected plugin compatibility if present
let compatibleTargets: Set<string> | null = preselectedPlugin?.includes
? getTargetsCompatibleWithIncludes(preselectedPlugin.includes)
: null
// Intersect with compatibility of all enabled plugins
if (enabledPlugins.length > 0) {
const pluginRegistry = registryData as Record<string, { includes?: string[] }>
const allCompatibleSets: Set<string>[] = []
for (const pluginId of enabledPlugins) {
const plugin = pluginRegistry[pluginId]
if (plugin?.includes && plugin.includes.length > 0) {
allCompatibleSets.push(getTargetsCompatibleWithIncludes(plugin.includes))
}
}
if (allCompatibleSets.length > 0) {
if (compatibleTargets) {
compatibleTargets = new Set(
Array.from(compatibleTargets).filter(target => allCompatibleSets.every(set => set.has(target)))
)
} else {
compatibleTargets = allCompatibleSets[0]
for (let i = 1; i < allCompatibleSets.length; i++) {
compatibleTargets = new Set(Array.from(compatibleTargets).filter(target => allCompatibleSets[i].has(target)))
}
}
} else if (!compatibleTargets) {
compatibleTargets = null
}
}
const filteredGroupedTargets = compatibleTargets
? Object.entries(GROUPED_TARGETS).reduce(
(acc, [category, targets]) => {
const filtered = targets.filter(target => {
const normalizedId = target.id.replace(/[-_]/g, "")
return compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
})
if (filtered.length > 0) {
acc[category] = filtered
}
return acc
},
{} as Record<string, TargetGroup[]>
)
: GROUPED_TARGETS
const filteredTargetCategories = Object.keys(filteredGroupedTargets).sort((a, b) => a.localeCompare(b))
return {
compatibleTargets,
filteredGroupedTargets,
filteredTargetCategories,
}
}

215
hooks/useTargetSelection.ts Normal file
View File

@@ -0,0 +1,215 @@
import { TARGETS } from "@/constants/targets"
import { useEffect, useState } from "react"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || "Other"
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
},
{} as Record<string, TargetGroup[]>
)
const TARGET_CATEGORIES = Object.keys(GROUPED_TARGETS).sort((a, b) => a.localeCompare(b))
const DEFAULT_TARGET =
TARGET_CATEGORIES.length > 0 && GROUPED_TARGETS[TARGET_CATEGORIES[0]]?.length
? GROUPED_TARGETS[TARGET_CATEGORIES[0]][0].id
: ""
const STORAGE_KEY = "quick_build_target"
const getStorageKeyForCategory = (category: string) => `quick_build_target_${category}`
export function useTargetSelection(
compatibleTargets: Set<string> | null,
filteredGroupedTargets: Record<string, TargetGroup[]>,
filteredTargetCategories: string[]
) {
const [activeCategory, setActiveCategory] = useState<string>(TARGET_CATEGORIES[0] ?? "")
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
const persistTargetSelection = (targetId: string, category?: string) => {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(STORAGE_KEY, targetId)
if (category) {
window.localStorage.setItem(getStorageKeyForCategory(category), targetId)
}
} catch (error) {
console.error("Failed to persist target selection", error)
}
}
const getSavedTargetForCategory = (category: string): string | null => {
if (typeof window === "undefined") return null
try {
return window.localStorage.getItem(getStorageKeyForCategory(category))
} catch (error) {
console.error("Failed to read saved target for category", error)
return null
}
}
const handleSelectTarget = (targetId: string) => {
if (compatibleTargets) {
const normalizedId = targetId.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(targetId) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
return
}
}
setSelectedTarget(targetId)
const category = TARGETS[targetId]?.category || "Other"
persistTargetSelection(targetId, category)
if (category && TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
// Initialize active category
useEffect(() => {
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (!activeCategory && categories.length > 0) {
setActiveCategory(categories[0])
}
}, [activeCategory, compatibleTargets, filteredTargetCategories])
// Handle category change - auto-select target
useEffect(() => {
if (activeCategory) {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categoryTargets = targets[activeCategory] || []
if (categoryTargets.length === 0) return
const isCurrentTargetInCategory = categoryTargets.some(t => t.id === selectedTarget)
if (!isCurrentTargetInCategory) {
const savedTargetForCategory = getSavedTargetForCategory(activeCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, activeCategory)
} else {
const firstTarget = categoryTargets[0].id
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, activeCategory)
}
}
}
}, [activeCategory, compatibleTargets, filteredGroupedTargets, selectedTarget])
// Restore saved target on mount
useEffect(() => {
if (typeof window === "undefined") return
try {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (categories.length === 0) return
const savedTarget = localStorage.getItem(STORAGE_KEY)
if (savedTarget && TARGETS[savedTarget]) {
const isCompatible = Object.values(targets).some(categoryTargets =>
categoryTargets.some(target => target.id === savedTarget)
)
if (isCompatible) {
const category = TARGETS[savedTarget].category || "Other"
if (categories.includes(category)) {
setActiveCategory(category)
setSelectedTarget(savedTarget)
persistTargetSelection(savedTarget, category)
return
}
}
}
const firstCategory = categories[0]
const categoryTargets = targets[firstCategory] || []
if (categoryTargets.length > 0) {
const savedTargetForCategory = getSavedTargetForCategory(firstCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setActiveCategory(firstCategory)
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, firstCategory)
} else {
const firstTarget = categoryTargets[0].id
setActiveCategory(firstCategory)
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, firstCategory)
}
}
} catch (error) {
console.error("Failed to read saved target", error)
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories])
// Update selected target if it becomes incompatible
useEffect(() => {
if (!selectedTarget || !compatibleTargets) return
const normalizedId = selectedTarget.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(selectedTarget) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
const targets = filteredGroupedTargets
const categories = filteredTargetCategories
if (categories.length > 0) {
const currentCategory = TARGETS[selectedTarget]?.category
if (currentCategory && targets[currentCategory] && targets[currentCategory].length > 0) {
const savedTargetForCategory = getSavedTargetForCategory(currentCategory)
const isValidSavedTarget =
savedTargetForCategory && targets[currentCategory].some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, currentCategory)
return
}
setSelectedTarget(targets[currentCategory][0].id)
persistTargetSelection(targets[currentCategory][0].id, currentCategory)
return
}
const firstCategory = categories[0]
const firstTarget = targets[firstCategory]?.[0]?.id
if (firstTarget) {
setSelectedTarget(firstTarget)
setActiveCategory(firstCategory)
persistTargetSelection(firstTarget, firstCategory)
}
}
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories, selectedTarget])
// Initialize storage
useEffect(() => {
if (typeof window === "undefined" || !selectedTarget) return
try {
if (!window.localStorage.getItem(STORAGE_KEY)) {
window.localStorage.setItem(STORAGE_KEY, selectedTarget)
}
} catch (error) {
console.error("Failed to initialize target storage", error)
}
}, [selectedTarget])
return {
activeCategory,
selectedTarget,
setActiveCategory,
handleSelectTarget,
GROUPED_TARGETS: GROUPED_TARGETS as Record<string, TargetGroup[]>,
TARGET_CATEGORIES,
}
}

View File

@@ -127,30 +127,19 @@ export function isRequiredByOther(
return false
}
/**
* Normalize architecture name (remove hyphens and underscores to match PlatformIO format)
* PlatformIO uses "esp32s3", "nrf52840" (no hyphens, no underscores)
* Hardware list uses "esp32-s3" (with hyphens)
* Some sources might use "esp32_s3" (with underscores)
*/
function normalizeArchitecture(arch: string): string {
return arch.replace(/[-_]/g, "")
}
/**
* Trace a target/variant/architecture back to its base architecture
* Follows the parent chain until it reaches a base architecture (null parent)
*/
export function getBaseArchitecture(name: string): string | null {
const normalized = normalizeArchitecture(name)
const parentMap = PARENT_MAP as Record<string, string | null>
const visited = new Set<string>()
let current = normalized
let current: string | null = name
while (current && !visited.has(current)) {
visited.add(current)
const parent = parentMap[current]
if (!current) break
const parent: string | null | undefined = parentMap[current]
// If parent is null, we've reached a base architecture
if (parent === null) {
@@ -162,11 +151,11 @@ export function getBaseArchitecture(name: string): string | null {
return current
}
current = normalizeArchitecture(parent)
current = parent
}
// Circular reference or unknown, return the last known
return current || normalized
return current || name
}
/**
@@ -174,17 +163,16 @@ export function getBaseArchitecture(name: string): string | null {
* (including itself and all parent architectures up to base)
*/
export function getCompatibleArchitectures(arch: string): string[] {
const normalized = normalizeArchitecture(arch)
const parentMap = PARENT_MAP as Record<string, string | null>
const compatible = [normalized]
const compatible = [arch]
const visited = new Set<string>()
let current = normalized
let current: string | null = arch
// Follow parent chain up to base architecture
while (current && !visited.has(current)) {
visited.add(current)
const parent = parentMap[current]
if (!current) break
const parent: string | null | undefined = parentMap[current]
if (parent === null) {
// Reached base architecture
@@ -196,12 +184,11 @@ export function getCompatibleArchitectures(arch: string): string[] {
break
}
const normalizedParent = normalizeArchitecture(parent)
if (!compatible.includes(normalizedParent)) {
compatible.push(normalizedParent)
if (!compatible.includes(parent)) {
compatible.push(parent)
}
current = normalizedParent
current = parent
}
return compatible
@@ -227,18 +214,16 @@ export function isPluginCompatibleWithTarget(
const parentMap = PARENT_MAP as Record<string, string | null>
// Normalize target name first (all keys in parentMap are normalized)
const normalizedTarget = normalizeArchitecture(targetName)
// Get all compatible names for the target (target itself + all parents up to base architecture)
const compatibleNames = new Set<string>([normalizedTarget])
const compatibleNames = new Set<string>([targetName])
const visited = new Set<string>()
let current = normalizedTarget
let current: string | null = targetName
// Follow parent chain (all keys and values in parentMap are already normalized)
// Follow parent chain
while (current && !visited.has(current)) {
visited.add(current)
const parent = parentMap[current]
if (!current) break
const parent: string | null | undefined = parentMap[current]
if (parent === null) {
// Reached base architecture
@@ -251,30 +236,21 @@ export function isPluginCompatibleWithTarget(
break
}
// Parent is already normalized (from JSON)
compatibleNames.add(parent)
current = parent
}
// Check excludes first - if target matches any exclude, it's incompatible
// compatibleNames are already normalized, normalize excludes for comparison
if (pluginExcludes && pluginExcludes.length > 0) {
const isExcluded = pluginExcludes.some(exclude => {
const normalizedExclude = normalizeArchitecture(exclude)
return compatibleNames.has(normalizedExclude)
})
const isExcluded = pluginExcludes.some(exclude => compatibleNames.has(exclude))
if (isExcluded) {
return false
}
}
// If includes are specified, target must match at least one include
// compatibleNames are already normalized, normalize includes for comparison
if (pluginIncludes && pluginIncludes.length > 0) {
return pluginIncludes.some(include => {
const normalizedInclude = normalizeArchitecture(include)
return compatibleNames.has(normalizedInclude)
})
return pluginIncludes.some(include => compatibleNames.has(include))
}
// If no includes/excludes specified, assume compatible with all (backward compatible)
@@ -301,34 +277,30 @@ export function isPluginCompatibleWithArchitecture(
export function getTargetsCompatibleWithIncludes(includes: string[]): Set<string> {
const parentMap = PARENT_MAP as Record<string, string | null>
const compatibleTargets = new Set<string>()
// Normalize includes
const normalizedIncludes = new Set(includes.map(include => normalizeArchitecture(include)))
const includesSet = new Set(includes)
// For each target in the parent map, check if it or any of its ancestors match the includes
for (const target of Object.keys(parentMap)) {
const normalizedTarget = normalizeArchitecture(target)
const visited = new Set<string>()
let current: string | null = normalizedTarget
let current: string | null = target
// Trace up the parent chain
while (current && !visited.has(current)) {
visited.add(current)
if (!current) break
// Check if current matches any of the includes
if (normalizedIncludes.has(current)) {
// Add both the normalized version and the original (for matching against TARGETS)
compatibleTargets.add(normalizedTarget)
if (includesSet.has(current)) {
compatibleTargets.add(target)
break
}
// Move to parent
const parentValue = parentMap[current]
if (parentValue === null || parentValue === undefined) {
const parent: string | null | undefined = parentMap[current]
if (parent === null || parent === undefined) {
break
}
current = normalizeArchitecture(parentValue)
current = parent
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "mesh-forge",
"version": "0.1.0",
"version": "0.4.0",
"private": true,
"author": "benallfree",
"license": "MIT",

View File

@@ -1,3 +1,4 @@
import Footer from "@/components/Footer"
import Navbar from "@/components/Navbar"
import { ConvexAuthProvider } from "@convex-dev/auth/react"
import { ConvexReactClient } from "convex/react"
@@ -18,8 +19,11 @@ function ConditionalNavbar() {
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<ConvexAuthProvider client={convex}>
<ConditionalNavbar />
{children}
<div className="min-h-screen flex flex-col">
<ConditionalNavbar />
<main className="flex-1">{children}</main>
<Footer />
</div>
</ConvexAuthProvider>
)
}

View File

@@ -11,5 +11,8 @@ export default {
"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: true,
prerender: {
partial: true,
},
ssr: false,
} satisfies Config

View File

@@ -1,33 +1,33 @@
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 BuildProgressPage() {
export default function BuildsPage() {
const pageContext = usePageContext()
const buildHash = pageContext.routeParams?.buildHash as string | undefined
const build = useQuery(api.builds.getByHash, buildHash ? { buildHash } : "skip")
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 <BuildViewPage buildHash={buildHash} />
}
// Otherwise, show the builder (handles clone and plugin params)
return <Builder cloneHash={cloneHash || undefined} pluginParam={pluginParam || undefined} />
}
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 (!buildHash) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto">
<p className="text-slate-300">
Build hash missing.{" "}
<a href="/builds/new" className="text-cyan-400">
Start a new build
</a>
.
</p>
</div>
</div>
)
}
if (build === undefined) {
return (
<div className="min-h-screen bg-slate-950 text-white p-8 flex items-center justify-center">
@@ -68,6 +68,7 @@ export default function BuildProgressPage() {
<div className="min-h-screen bg-slate-950 text-white p-8">
<div className="max-w-4xl mx-auto space-y-6">
<BuildProgress build={build} isAdmin={isAdmin === true} onRetry={handleRetry} />
<GiscusComments build={build} />
</div>
</div>
)

View File

@@ -1,3 +0,0 @@
export default {
prerender: false,
}

View File

@@ -1,5 +0,0 @@
import Builder from "./Builder"
export default function BuildNew() {
return <Builder />
}

View File

@@ -1,5 +0,0 @@
import Builder from "../Builder"
export default function BuildNew() {
return <Builder />
}

View File

@@ -1,3 +0,0 @@
export default {
prerender: false,
}

View File

@@ -1,857 +0,0 @@
import { ModuleToggle } from "@/components/ModuleToggle"
import { PluginCard } from "@/components/PluginCard"
import { Button } from "@/components/ui/button"
import { TARGETS } from "@/constants/targets"
import { VERSIONS } from "@/constants/versions"
import { api } from "@/convex/_generated/api"
import modulesData from "@/convex/modules.json"
import {
getDependedPlugins,
getImplicitDependencies,
getTargetsCompatibleWithIncludes,
isPluginCompatibleWithTarget,
isRequiredByOther,
} from "@/lib/utils"
import registryData from "@/public/registry.json"
import { useMutation, useQuery } from "convex/react"
import { CheckCircle2, ChevronDown, ChevronRight, Loader2 } from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { usePageContext } from "vike-react/usePageContext"
import { navigate } from "vike/client/router"
type TargetGroup = (typeof TARGETS)[string] & { id: string }
const GROUPED_TARGETS = Object.entries(TARGETS).reduce(
(acc, [id, meta]) => {
const category = meta.category || "Other"
if (!acc[category]) acc[category] = []
acc[category].push({ id, ...meta })
return acc
},
{} as Record<string, TargetGroup[]>
)
const TARGET_CATEGORIES = Object.keys(GROUPED_TARGETS).sort((a, b) => a.localeCompare(b))
const DEFAULT_TARGET =
TARGET_CATEGORIES.length > 0 && GROUPED_TARGETS[TARGET_CATEGORIES[0]]?.length
? GROUPED_TARGETS[TARGET_CATEGORIES[0]][0].id
: ""
export default function BuildNew() {
const pageContext = usePageContext()
const buildHashParam = pageContext.routeParams?.buildHash
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
const sharedBuild = useQuery(api.builds.getByHash, buildHashParam ? { buildHash: buildHashParam } : "skip")
// Get plugin from URL query parameter
const pluginParam = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("plugin") : null
const preselectedPlugin =
pluginParam && pluginParam in registryData
? (
registryData as Record<
string,
{ includes?: string[]; name: string; description: string; imageUrl?: string; featured?: boolean }
>
)[pluginParam]
: null
const STORAGE_KEY = "quick_build_target"
const getStorageKeyForCategory = (category: string) => `quick_build_target_${category}`
const persistTargetSelection = (targetId: string, category?: string) => {
if (typeof window === "undefined") return
try {
// Store global most recent selection
window.localStorage.setItem(STORAGE_KEY, targetId)
// Store per-brand selection if category provided
if (category) {
window.localStorage.setItem(getStorageKeyForCategory(category), targetId)
}
} catch (error) {
console.error("Failed to persist target selection", error)
}
}
const getSavedTargetForCategory = (category: string): string | null => {
if (typeof window === "undefined") return null
try {
return window.localStorage.getItem(getStorageKeyForCategory(category))
} catch (error) {
console.error("Failed to read saved target for category", error)
return null
}
}
const [activeCategory, setActiveCategory] = useState<string>(TARGET_CATEGORIES[0] ?? "")
const [selectedTarget, setSelectedTarget] = useState<string>(DEFAULT_TARGET)
const [selectedVersion, setSelectedVersion] = useState<string>(VERSIONS[0])
const [moduleConfig, setModuleConfig] = useState<Record<string, boolean>>({})
const [pluginConfig, setPluginConfig] = useState<Record<string, boolean>>({})
const [isFlashing, setIsFlashing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [showModuleOverrides, setShowModuleOverrides] = useState(false)
const [showPlugins, setShowPlugins] = useState(true)
const [isLoadingSharedBuild, setIsLoadingSharedBuild] = useState(false)
// Get all enabled plugins
const enabledPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Filter targets based on plugin compatibility
// Start with preselected plugin compatibility if present
let compatibleTargets: Set<string> | null = preselectedPlugin?.includes
? getTargetsCompatibleWithIncludes(preselectedPlugin.includes)
: null
// Intersect with compatibility of all enabled plugins
if (enabledPlugins.length > 0) {
const pluginRegistry = registryData as Record<string, { includes?: string[] }>
const allCompatibleSets: Set<string>[] = []
// Get compatible targets for each enabled plugin
for (const pluginId of enabledPlugins) {
const plugin = pluginRegistry[pluginId]
if (plugin?.includes && plugin.includes.length > 0) {
// Plugin has includes - get compatible targets
allCompatibleSets.push(getTargetsCompatibleWithIncludes(plugin.includes))
}
// If plugin has no includes, it's compatible with all targets (don't add to set)
}
// If we have compatible sets, find intersection
if (allCompatibleSets.length > 0) {
if (compatibleTargets) {
// Intersect with preselected plugin compatibility
compatibleTargets = new Set(
Array.from(compatibleTargets).filter(target => allCompatibleSets.every(set => set.has(target)))
)
} else {
// Start with first set, then intersect with others
compatibleTargets = allCompatibleSets[0]
for (let i = 1; i < allCompatibleSets.length; i++) {
compatibleTargets = new Set(Array.from(compatibleTargets).filter(target => allCompatibleSets[i].has(target)))
}
}
} else if (!compatibleTargets) {
// No enabled plugins have includes, so all targets are compatible
// (only if there's no preselected plugin with includes)
compatibleTargets = null
}
}
const filteredGroupedTargets = compatibleTargets
? Object.entries(GROUPED_TARGETS).reduce(
(acc, [category, targets]) => {
const filtered = targets.filter(target => {
// Check both normalized and original target ID
const normalizedId = target.id.replace(/[-_]/g, "")
return compatibleTargets.has(target.id) || compatibleTargets.has(normalizedId)
})
if (filtered.length > 0) {
acc[category] = filtered
}
return acc
},
{} as Record<string, TargetGroup[]>
)
: GROUPED_TARGETS
const filteredTargetCategories = Object.keys(filteredGroupedTargets).sort((a, b) => a.localeCompare(b))
// Preselect plugin from URL parameter
useEffect(() => {
if (pluginParam && preselectedPlugin && !buildHashParam) {
setPluginConfig({ [pluginParam]: true })
setShowPlugins(true)
}
}, [pluginParam, preselectedPlugin, buildHashParam])
useEffect(() => {
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (!activeCategory && categories.length > 0) {
setActiveCategory(categories[0])
}
}, [activeCategory, compatibleTargets, filteredTargetCategories])
useEffect(() => {
if (activeCategory) {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categoryTargets = targets[activeCategory] || []
if (categoryTargets.length === 0) return
// Check if current selected target is in this category
const isCurrentTargetInCategory = categoryTargets.some(t => t.id === selectedTarget)
if (!isCurrentTargetInCategory) {
// Try to restore per-brand selection
const savedTargetForCategory = getSavedTargetForCategory(activeCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
// Persist the restored selection
persistTargetSelection(savedTargetForCategory, activeCategory)
} else {
// Default to first target in category and persist it
const firstTarget = categoryTargets[0].id
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, activeCategory)
}
}
}
}, [activeCategory, compatibleTargets, filteredGroupedTargets, selectedTarget])
useEffect(() => {
if (typeof window === "undefined") return
try {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
const categories = compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES
if (categories.length === 0) return
// Try to restore the most recent global selection first
const savedTarget = localStorage.getItem(STORAGE_KEY)
if (savedTarget && TARGETS[savedTarget]) {
// Check if saved target exists in filtered targets
const isCompatible = Object.values(targets).some(categoryTargets =>
categoryTargets.some(target => target.id === savedTarget)
)
if (isCompatible) {
const category = TARGETS[savedTarget].category || "Other"
if (categories.includes(category)) {
setActiveCategory(category)
setSelectedTarget(savedTarget)
persistTargetSelection(savedTarget, category)
return
}
}
}
// Fall back to per-brand selection for first category
const firstCategory = categories[0]
const categoryTargets = targets[firstCategory] || []
if (categoryTargets.length > 0) {
// Try to restore per-brand selection
const savedTargetForCategory = getSavedTargetForCategory(firstCategory)
const isValidSavedTarget = savedTargetForCategory && categoryTargets.some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setActiveCategory(firstCategory)
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, firstCategory)
} else {
// Default to first target in category
const firstTarget = categoryTargets[0].id
setActiveCategory(firstCategory)
setSelectedTarget(firstTarget)
persistTargetSelection(firstTarget, firstCategory)
}
}
} catch (error) {
console.error("Failed to read saved target", error)
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories])
const handleSelectTarget = (targetId: string) => {
// Validate target is compatible with selected plugins
if (compatibleTargets) {
const normalizedId = targetId.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(targetId) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
// Target is not compatible, don't allow selection
return
}
}
setSelectedTarget(targetId)
const category = TARGETS[targetId]?.category || "Other"
persistTargetSelection(targetId, category)
if (category && TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
// Update selected target if it becomes incompatible with selected plugins
useEffect(() => {
if (!selectedTarget || !compatibleTargets) return
const normalizedId = selectedTarget.replace(/[-_]/g, "")
const isCompatible = compatibleTargets.has(selectedTarget) || compatibleTargets.has(normalizedId)
if (!isCompatible) {
// Current target is no longer compatible, find a compatible one
const targets = filteredGroupedTargets
const categories = filteredTargetCategories
if (categories.length > 0) {
// Try to find a compatible target in the current category first
const currentCategory = TARGETS[selectedTarget]?.category
if (currentCategory && targets[currentCategory] && targets[currentCategory].length > 0) {
const savedTargetForCategory = getSavedTargetForCategory(currentCategory)
const isValidSavedTarget =
savedTargetForCategory && targets[currentCategory].some(t => t.id === savedTargetForCategory)
if (isValidSavedTarget) {
setSelectedTarget(savedTargetForCategory)
persistTargetSelection(savedTargetForCategory, currentCategory)
return
}
// Default to first target in current category
setSelectedTarget(targets[currentCategory][0].id)
persistTargetSelection(targets[currentCategory][0].id, currentCategory)
return
}
// Fall back to first compatible target
const firstCategory = categories[0]
const firstTarget = targets[firstCategory]?.[0]?.id
if (firstTarget) {
setSelectedTarget(firstTarget)
setActiveCategory(firstCategory)
persistTargetSelection(firstTarget, firstCategory)
}
}
}
}, [compatibleTargets, filteredGroupedTargets, filteredTargetCategories, selectedTarget])
useEffect(() => {
if (typeof window === "undefined" || !selectedTarget) return
try {
if (!window.localStorage.getItem(STORAGE_KEY)) {
window.localStorage.setItem(STORAGE_KEY, selectedTarget)
}
} catch (error) {
console.error("Failed to initialize target storage", error)
}
}, [selectedTarget])
// Pre-populate form from shared build
useEffect(() => {
if (!buildHashParam) return
if (sharedBuild === undefined) {
setIsLoadingSharedBuild(true)
return
}
setIsLoadingSharedBuild(false)
if (!sharedBuild) {
setErrorMessage("Build not found. The shared build may have been deleted.")
toast.error("Build not found", {
description: "The shared build could not be loaded.",
})
return
}
const config = sharedBuild.config
// Set target and category
if (config.target && TARGETS[config.target]) {
setSelectedTarget(config.target)
const category = TARGETS[config.target].category || "Other"
if (TARGET_CATEGORIES.includes(category)) {
setActiveCategory(category)
}
}
// Set version
if (config.version && (VERSIONS as readonly string[]).includes(config.version)) {
setSelectedVersion(config.version as (typeof VERSIONS)[number])
}
// Set module config (already in the correct format)
if (config.modulesExcluded) {
setModuleConfig(config.modulesExcluded)
if (Object.keys(config.modulesExcluded).length > 0) {
setShowModuleOverrides(true)
}
}
// Set plugin config (convert array to object format)
// Only add explicitly selected plugins, not implicit dependencies
if (config.pluginsEnabled && config.pluginsEnabled.length > 0) {
const allPluginSlugs = config.pluginsEnabled.map(pluginId => {
return pluginId.includes("@") ? pluginId.split("@")[0] : pluginId
})
// Determine which plugins are required by others (implicit dependencies)
const requiredByOthers = new Set<string>()
for (const pluginSlug of allPluginSlugs) {
if (
isRequiredByOther(
pluginSlug,
allPluginSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
) {
requiredByOthers.add(pluginSlug)
}
}
// Only add plugins that are NOT required by others (explicitly selected)
const pluginObj: Record<string, boolean> = {}
allPluginSlugs.forEach(slug => {
if (slug in registryData && !requiredByOthers.has(slug)) {
pluginObj[slug] = true
}
})
setPluginConfig(pluginObj)
setShowPlugins(true)
}
}, [buildHashParam, sharedBuild])
const moduleCount = Object.keys(moduleConfig).length
const pluginCount = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true).length
const selectedTargetLabel = (selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
const handleToggleModule = (id: string, excluded: boolean) => {
setModuleConfig(prev => {
const next = { ...prev }
if (excluded) {
next[id] = true
} else {
delete next[id]
}
return next
})
}
const handleTogglePlugin = (id: string, enabled: boolean) => {
// Get current explicit selections
const explicitPlugins = Object.keys(pluginConfig).filter(pluginId => pluginConfig[pluginId] === true)
// Check if this plugin is currently an implicit dependency
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Check if this plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
id,
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Don't allow toggling implicit dependencies at all
// (they should be disabled in the UI, but add this as a safeguard)
if (implicitDeps.has(id)) {
return // Can't toggle implicit dependencies
}
// Don't allow disabling if it's required by another explicitly selected plugin
if (!enabled && isRequired) {
return // Can't disable required plugins
}
setPluginConfig(prev => {
const next = { ...prev }
if (enabled) {
// Enabling: add to explicit selection (even if it was implicit)
next[id] = true
} else {
// Disabling: remove from explicit selection
delete next[id]
// Recompute what plugins are still needed after removal
const remainingExplicit = Object.keys(next).filter(pluginId => next[pluginId] === true)
const allStillNeeded = getDependedPlugins(
remainingExplicit,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Remove any plugins from config that are no longer needed
// BUT preserve all plugins that are currently explicitly selected (in remainingExplicit)
// This ensures that plugins that were explicitly selected remain explicitly selected
// even if they temporarily became implicit and then un-implicit
for (const pluginId of Object.keys(next)) {
if (next[pluginId] === true && !allStillNeeded.includes(pluginId) && !remainingExplicit.includes(pluginId)) {
// This plugin is no longer needed and is not in the remaining explicit list
// Only remove if it's truly not needed and wasn't explicitly selected
// Note: If a plugin is in `next` with value `true`, it should be in `remainingExplicit`
// So this condition should rarely be true, but we keep it as a safety check
delete next[pluginId]
}
}
// Ensure all remaining explicitly selected plugins stay in config
// (they should already be there, but this ensures they remain even if they're not needed)
for (const pluginId of remainingExplicit) {
next[pluginId] = true
}
}
return next
})
}
const handleFlash = async () => {
if (!selectedTarget) return
setIsFlashing(true)
setErrorMessage(null)
try {
const enabledSlugs = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Double-check: filter out any implicit dependencies that might have snuck in
// This ensures we only send explicitly selected plugins to the backend
const implicitDeps = getImplicitDependencies(
enabledSlugs,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
const explicitOnlySlugs = enabledSlugs.filter(slug => !implicitDeps.has(slug))
const pluginsEnabled = explicitOnlySlugs.map(slug => {
const plugin = (registryData as Record<string, { version: string }>)[slug]
return `${slug}@${plugin.version}`
})
const result = await ensureBuildFromConfig({
target: selectedTarget,
version: selectedVersion,
modulesExcluded: moduleConfig,
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
})
navigate(`/builds/${result.buildHash}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setErrorMessage("Failed to start build. Please try again.")
toast.error("Failed to start build", {
description: message,
})
} finally {
setIsFlashing(false)
}
}
const isFlashDisabled = !selectedTarget || isFlashing
if (isLoadingSharedBuild) {
return (
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10 flex items-center justify-center">
<div className="text-center space-y-4">
<Loader2 className="w-8 h-8 animate-spin text-cyan-500 mx-auto" />
<p className="text-slate-400">Loading shared build configuration...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-950 text-white p-6 md:p-10">
<div className="max-w-6xl mx-auto space-y-8">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<p className="text-sm uppercase tracking-wider text-slate-500">
{preselectedPlugin ? "Plugin build" : "Quick build"}
</p>
<h1 className="text-4xl font-bold mt-1">
{preselectedPlugin ? `Build firmware for ${preselectedPlugin.name}` : "Flash a custom firmware version"}
</h1>
<p className="text-slate-400 mt-2 max-w-2xl">
{preselectedPlugin
? `Select a compatible Meshtastic target and configure your build for ${preselectedPlugin.name}. We'll send you to the build status page as soon as it starts.`
: "Choose your Meshtastic target, adjust optional modules, and queue a new build instantly. We'll send you to the build status page as soon as it starts."}
</p>
</div>
</div>
{preselectedPlugin && (
<div className="bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="flex items-start gap-4">
<CheckCircle2 className="w-8 h-8 text-green-400 shrink-0 mt-1" />
<div className="flex items-start gap-4 flex-1">
{preselectedPlugin.imageUrl && (
<img
src={preselectedPlugin.imageUrl}
alt={`${preselectedPlugin.name} logo`}
className="w-16 h-16 rounded-lg object-contain shrink-0"
/>
)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h2 className="text-2xl font-bold">{preselectedPlugin.name}</h2>
{preselectedPlugin.featured && (
<span className="px-2 py-1 text-xs font-medium text-green-400 bg-green-400/10 border border-green-400/20 rounded">
Featured
</span>
)}
</div>
<p className="text-slate-400 mb-3">{preselectedPlugin.description}</p>
{preselectedPlugin.includes && preselectedPlugin.includes.length > 0 && (
<p className="text-sm text-slate-500">Compatible with: {preselectedPlugin.includes.join(", ")}</p>
)}
</div>
</div>
</div>
</div>
)}
<div className="space-y-6 bg-slate-900/60 border border-slate-800 rounded-2xl p-6">
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{(compatibleTargets ? filteredTargetCategories : TARGET_CATEGORIES).map(category => {
const isActive = activeCategory === category
return (
<button
key={category}
type="button"
onClick={() => {
// Always allow switching to category - the useEffect will handle target selection
setActiveCategory(category)
}}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isActive ? "bg-blue-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
}`}
>
{category}
</button>
)
})}
</div>
<div className="bg-slate-950/60 p-4 rounded-lg border border-slate-800/60">
<div className="flex flex-wrap gap-2">
{(() => {
const targets = compatibleTargets ? filteredGroupedTargets : GROUPED_TARGETS
return (activeCategory ? targets[activeCategory] : [])?.map(target => {
const isSelected = selectedTarget === target.id
return (
<button
key={target.id}
type="button"
onClick={() => handleSelectTarget(target.id)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
isSelected ? "bg-cyan-600 text-white" : "bg-slate-800 text-slate-300 hover:bg-slate-700"
}`}
>
{target.name}
</button>
)
})
})()}
</div>
</div>
</div>
<div>
<label htmlFor="build-version" className="block text-sm font-medium mb-2">
Firmware version
</label>
<select
id="build-version"
value={selectedVersion}
onChange={event => setSelectedVersion(event.target.value)}
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
>
{VERSIONS.map(version => (
<option key={version} value={version}>
{version}
</option>
))}
</select>
</div>
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button
type="button"
onClick={() => setShowModuleOverrides(prev => !prev)}
className="w-full flex items-center justify-between text-left"
>
<div>
<p className="text-sm font-medium">Core Modules</p>
<p className="text-xs text-slate-400">
{moduleCount === 0
? "Using default modules for this target."
: `${moduleCount} module${moduleCount === 1 ? "" : "s"} excluded.`}
</p>
</div>
{showModuleOverrides ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showModuleOverrides && (
<div className="space-y-2 pr-1">
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
<p className="text-xs text-slate-400 leading-relaxed">
Core Modules are officially maintained modules by Meshtastic. They are selectively included or
excluded by default depending on the target device. You can explicitly exclude modules you know you
don't want.
</p>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={() => setModuleConfig({})}
disabled={moduleCount === 0}
>
Reset overrides
</button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{modulesData.modules.map(module => (
<ModuleToggle
key={module.id}
id={module.id}
name={module.name}
description={module.description}
isExcluded={moduleConfig[module.id] === true}
onToggle={excluded => handleToggleModule(module.id, excluded)}
/>
))}
</div>
</div>
)}
</div>
<div className="space-y-3 rounded-2xl border border-slate-800 bg-slate-950/70 p-6">
<button
type="button"
onClick={() => setShowPlugins(prev => !prev)}
className="w-full flex items-center justify-between text-left"
>
<div>
<p className="text-sm font-medium">Plugins</p>
<p className="text-xs text-slate-400">
{pluginCount === 0
? "No plugins enabled."
: `${pluginCount} plugin${pluginCount === 1 ? "" : "s"} enabled.`}
</p>
</div>
{showPlugins ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
</button>
{showPlugins && (
<div className="space-y-2 pr-1">
<div className="rounded-lg bg-slate-800/50 border border-slate-700 p-3">
<p className="text-xs text-slate-400 leading-relaxed">
Plugins are 3rd party add-ons. They are not maintained, endorsed, or supported by Meshtastic. Use at
your own risk.
</p>
</div>
<div className="flex justify-end">
<button
type="button"
className="text-xs text-slate-400 hover:text-white underline"
onClick={() => setPluginConfig({})}
disabled={pluginCount === 0}
>
Reset plugins
</button>
</div>
<div className="grid gap-2 md:grid-cols-2" key={`plugins-${selectedTarget}`}>
{(() => {
// Get explicitly selected plugins (user-selected)
const explicitPlugins = Object.keys(pluginConfig).filter(id => pluginConfig[id] === true)
// Compute implicit dependencies (dependencies that are not explicitly selected)
const implicitDeps = getImplicitDependencies(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Compute all enabled plugins (explicit + implicit)
const allEnabledPlugins = getDependedPlugins(
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
return 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)
})
.map(([slug, plugin]) => {
// Check if plugin is required by another explicitly selected plugin
const isRequired = isRequiredByOther(
slug,
explicitPlugins,
registryData as Record<string, { dependencies?: Record<string, string> }>
)
// Plugin is implicit if it's either:
// 1. Not explicitly selected but is a dependency, OR
// 2. Explicitly selected but required by another explicitly selected plugin
const isImplicit = implicitDeps.has(slug) || (explicitPlugins.includes(slug) && isRequired)
// Check plugin compatibility with selected target
const pluginIncludes = (plugin as { includes?: string[] }).includes
const pluginExcludes = (plugin as { excludes?: string[] }).excludes
// Legacy support: check for old "architectures" field
const legacyArchitectures = (plugin as { architectures?: string[] }).architectures
const hasCompatibilityConstraints =
(pluginIncludes && pluginIncludes.length > 0) ||
(pluginExcludes && pluginExcludes.length > 0) ||
(legacyArchitectures && legacyArchitectures.length > 0)
const isCompatible =
hasCompatibilityConstraints && selectedTarget
? isPluginCompatibleWithTarget(
pluginIncludes || legacyArchitectures,
pluginExcludes,
selectedTarget
)
: true // If no constraints or no target selected, assume compatible
// Mark as incompatible if plugin has compatibility constraints and target is not compatible
const isIncompatible = !isCompatible && hasCompatibilityConstraints && !!selectedTarget
// Check if this is the preselected plugin from URL
const isPreselected = pluginParam === slug
return (
<PluginCard
key={`${slug}-${selectedTarget}`}
variant="link-toggle"
id={slug}
name={plugin.name}
description={plugin.description}
imageUrl={plugin.imageUrl}
isEnabled={allEnabledPlugins.includes(slug)}
onToggle={enabled => handleTogglePlugin(slug, enabled)}
disabled={isImplicit || isIncompatible || isPreselected}
enabledLabel={isPreselected ? "Locked" : isImplicit ? "Required" : "Add"}
incompatibleReason={isIncompatible ? "Not compatible with this target" : undefined}
featured={plugin.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage}
version={plugin.version}
repo={plugin.repo}
/>
)
})
})()}
</div>
</div>
)}
</div>
<div className="space-y-2">
<Button onClick={handleFlash} disabled={isFlashDisabled} className="w-full bg-cyan-600 hover:bg-cyan-700">
{isFlashing ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Queuing build...
</span>
) : (
`Flash ${selectedTargetLabel || ""}`.trim() || "Flash"
)}
</Button>
{errorMessage && <p className="text-sm text-red-400">{errorMessage}</p>}
</div>
</div>
</div>
</div>
)
}

View File

@@ -28,4 +28,4 @@ While the build tools themselves (PlatformIO, pip, etc.) still require internet
## Getting Started
Ready to build your custom firmware? Visit the [builder](https://meshforge.org/builds/new) to get started, or explore the [Plugin Registry](/docs/registry) to see what's available.
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.

View File

@@ -10,9 +10,9 @@ The registry contains plugins that add new features and capabilities to Meshtast
### Using Mesh Forge (Recommended)
The easiest way to use plugins is through [Mesh Forge](https://meshforge.org/builds/new), which lets you build custom firmware with plugins directly in your browser:
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/new](https://meshforge.org/builds/new)
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

View File

@@ -149,7 +149,7 @@ export default function LandingPage() {
description={customBuildPlugin.description}
imageUrl={customBuildPlugin.imageUrl}
featured={false}
href="/builds/new"
href="/builds"
prominent={true}
/>
</div>

View File

@@ -0,0 +1,7 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<article className="prose prose-invert lg:prose-xl max-w-none">{children}</article>
</div>
)
}

36
pages/license/+Page.mdx Normal file
View File

@@ -0,0 +1,36 @@
# 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)

View File

@@ -132,7 +132,7 @@ export default function PluginPage() {
Homepage
</Button>
)}
<Button onClick={() => navigate(`/builds/new?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
<Button onClick={() => navigate(`/builds?plugin=${slug}`)} className="bg-cyan-600 hover:bg-cyan-700">
Build with this Plugin
</Button>
</div>

View File

@@ -1,4 +0,0 @@
export default {
prerender: false,
}

View File

@@ -0,0 +1,8 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<article className="prose prose-invert lg:prose-xl max-w-none">{children}</article>
</div>
)
}

108
pages/privacy/+Page.mdx Normal file
View File

@@ -0,0 +1,108 @@
# Privacy Policy
**Last Updated: December 2025**
MeshForge.org is owned and operated by MeshEnvy NCC, a Nevada 501(c)(3) Nonprofit Corporation.
MeshForge ("we", "our", or "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and safeguard your information when you use our website and services.
## Information We Collect
### Account Information
When you sign in using GitHub OAuth, we collect:
- Your GitHub username and user ID
- Your GitHub profile information (name, email if publicly available)
- Authentication tokens provided by GitHub
### Build and Configuration Data
When you create firmware builds, we collect and store:
- Build configurations (selected modules, plugins, target hardware)
- Build status and completion information
- Build artifacts (firmware binaries and source code archives)
- Build history and timestamps
### Usage Data
We automatically collect certain information when you use our service:
- Build requests and download activity
- Plugin usage statistics
- Timestamps of actions
- IP addresses (for security and abuse prevention)
### Public Profiles
If you choose to make a build profile public, the following information may be visible to other users:
- Profile name and description
- Build configuration
- Flash/download counts
## How We Use Your Information
We use the information we collect to:
- Provide and maintain our firmware building service
- Process your build requests and deliver firmware binaries
- Store your build configurations for future use
- Generate download statistics and usage analytics
- Prevent abuse and ensure service security
- Improve our service and user experience
## Data Storage and Security
- Your data is stored securely using Convex cloud infrastructure
- Build artifacts are stored in cloud storage (R2/S3) with secure access controls
- We implement industry-standard security measures to protect your data
- Authentication is handled securely through GitHub OAuth
## Data Retention
- Build configurations and profiles are retained until you delete them
- Build artifacts may be retained for a reasonable period to ensure availability
- We may retain certain data for legal compliance and abuse prevention
## Your Rights
You have the right to:
- Access your personal data
- Delete your account and associated data
- Make your profiles private or public
- Request a copy of your data
To exercise these rights, contact us at [legal@meshforge.org](mailto:legal@meshforge.org).
## Third-Party Services
We use the following third-party services:
- **Convex**: Backend database and authentication services
- **GitHub**: OAuth authentication provider
- **Cloud Storage Providers**: For storing build artifacts
These services have their own privacy policies. We recommend reviewing them.
## Cookies and Tracking
We use minimal cookies and tracking technologies:
- Authentication session cookies (required for service functionality)
- No third-party advertising or marketing cookies
- No cross-site tracking
## Children's Privacy
Our service is not intended for users under the age of 13. We do not knowingly collect personal information from children under 13.
## Changes to This Policy
We may update this Privacy Policy from time to time. We will notify you of any changes by posting the new Privacy Policy on this page and updating the "Last Updated" date.
## Contact Us
If you have questions about this Privacy Policy, please contact us at [legal@meshforge.org](mailto:legal@meshforge.org).

8
pages/terms/+Layout.tsx Normal file
View File

@@ -0,0 +1,8 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<article className="prose prose-invert lg:prose-xl max-w-none">{children}</article>
</div>
)
}

123
pages/terms/+Page.mdx Normal file
View File

@@ -0,0 +1,123 @@
# Terms of Service
**Last Updated: January 2025**
MeshForge.org is owned and operated by MeshEnvy NCC, a Nevada 501(c)(3) Nonprofit Corporation.
Please read these Terms of Service ("Terms") carefully before using MeshForge ("the Service") operated by MeshForge.org ("we", "us", or "our").
## Acceptance of Terms
By accessing or using MeshForge, you agree to be bound by these Terms. If you disagree with any part of these terms, you may not access the Service.
## Description of Service
MeshForge is a cloud-based firmware builder that allows you to:
- Create custom Meshtastic firmware builds
- Select modules and plugins for inclusion
- Download firmware binaries and source code
- Share build configurations with others
## User Accounts
### Account Requirements
- You must be at least 13 years old to use this Service
- You must provide accurate and complete information when creating an account
- You are responsible for maintaining the security of your account
- You are responsible for all activities that occur under your account
### GitHub Authentication
- MeshForge uses GitHub OAuth for authentication
- By signing in, you authorize us to access your GitHub account information
- You must comply with GitHub's Terms of Service
## Acceptable Use
You agree not to:
- Use the Service for any illegal purpose or in violation of any laws
- Attempt to gain unauthorized access to the Service or related systems
- Interfere with or disrupt the Service or servers
- Use the Service to build firmware that violates third-party rights
- Abuse, harass, or harm other users
- Upload malicious code or attempt to exploit vulnerabilities
- Use automated systems to access the Service without permission
## Build Limitations
- Builds are subject to resource limitations and may be rate-limited
- We reserve the right to reject or cancel builds that violate these Terms
- Build artifacts may be removed after a reasonable retention period
- We are not responsible for firmware that fails to compile or function
## Intellectual Property
### Your Content
- You retain ownership of build configurations you create
- You grant us a license to store, process, and serve your builds
- Public profiles may be visible to other users
### MeshForge Content
- The MeshForge website and tooling are licensed under the MIT License
- Generated firmware projects are licensed under GPLv3 (see [License](/license))
- Third-party plugins retain their original licenses
### Third-Party Code
- Generated firmware includes upstream Meshtastic code (GPLv3)
- Plugins may have their own licenses
- You are responsible for complying with all applicable licenses
## Disclaimers
### Service Availability
- The Service is provided "as is" and "as available"
- We do not guarantee uninterrupted or error-free operation
- We reserve the right to modify or discontinue the Service at any time
### Firmware Disclaimer
- **USE AT YOUR OWN RISK**: Firmware built through MeshForge may not function correctly
- We are not responsible for damage to hardware or data loss
- We do not guarantee compatibility with any specific hardware
- Always test firmware in a safe environment before deployment
### No Warranty
THE SERVICE IS PROVIDED WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT.
## Limitation of Liability
TO THE MAXIMUM EXTENT PERMITTED BY LAW, MESHFORGE.ORG AND MESHENVY NCC SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR ANY LOSS OF PROFITS OR REVENUES, WHETHER INCURRED DIRECTLY OR INDIRECTLY, OR ANY LOSS OF DATA, USE, GOODWILL, OR OTHER INTANGIBLE LOSSES.
## Indemnification
You agree to indemnify and hold harmless MeshForge.org and MeshEnvy NCC from any claims, damages, losses, liabilities, and expenses (including legal fees) arising out of your use of the Service or violation of these Terms.
## Termination
We may terminate or suspend your account immediately, without prior notice, for conduct that we believe violates these Terms or is harmful to other users, us, or third parties.
You may terminate your account at any time by contacting us or deleting your account through the Service.
## Changes to Terms
We reserve the right to modify these Terms at any time. We will notify users of material changes by posting the updated Terms on this page. Your continued use of the Service after changes constitutes acceptance of the new Terms.
## Governing Law
These Terms shall be governed by and construed in accordance with the laws of the jurisdiction in which MeshForge.org operates, without regard to its conflict of law provisions.
## Severability
If any provision of these Terms is found to be unenforceable or invalid, that provision shall be limited or eliminated to the minimum extent necessary, and the remaining provisions shall remain in full force and effect.
## Contact Information
If you have questions about these Terms, please contact us at [legal@meshforge.org](mailto:legal@meshforge.org).

View File

@@ -9,14 +9,6 @@ const FIRMWARE_DIR = path.resolve(__dirname, "../vendor/firmware")
const VARIANTS_DIR = path.join(FIRMWARE_DIR, "variants")
const OUTPUT_FILE = path.resolve(__dirname, "../constants/architecture-hierarchy.json")
/**
* Normalize architecture/target name (remove hyphens and underscores)
* This ensures consistent format matching PlatformIO architecture names
*/
function normalizeName(name) {
return name.replace(/[-_]/g, "")
}
/**
* Parse PlatformIO ini file to extract sections and their properties
*/
@@ -342,15 +334,8 @@ function buildParentMapping() {
delete resolvedParentMap[key]
}
// Normalize all keys and values (strip hyphens and underscores)
const normalizedMap = {}
for (const [key, value] of Object.entries(resolvedParentMap)) {
const normalizedKey = normalizeName(key)
const normalizedValue = value !== null ? normalizeName(value) : null
normalizedMap[normalizedKey] = normalizedValue
}
return normalizedMap
// Return map with actual PlatformIO environment names (no normalization)
return resolvedParentMap
}
/**

1
vendor/api vendored

Submodule vendor/api deleted from 1774354d2b

1
vendor/firmware vendored

Submodule vendor/firmware deleted from aa72e397f2

1
vendor/lofs vendored Submodule

Submodule vendor/lofs added at 6e6ac2b367

1
vendor/meshcore vendored

Submodule vendor/meshcore deleted from 6d3219329f

1
vendor/web-flasher vendored

Submodule vendor/web-flasher deleted from c165572117