feat: implement plugin management system with support for enabling/disabling plugins and tracking flash counts

This commit is contained in:
Ben Allfree
2025-11-29 17:16:13 -08:00
parent caecfea95f
commit 9786192bee
9 changed files with 279 additions and 7 deletions
+2
View File
@@ -13,6 +13,7 @@ import type * as auth from "../auth.js";
import type * as builds from "../builds.js";
import type * as http from "../http.js";
import type * as lib_r2 from "../lib/r2.js";
import type * as plugins from "../plugins.js";
import type * as profiles from "../profiles.js";
import type {
@@ -27,6 +28,7 @@ declare const fullApi: ApiFromModules<{
builds: typeof builds;
http: typeof http;
"lib/r2": typeof lib_r2;
plugins: typeof plugins;
profiles: typeof profiles;
}>;
+2
View File
@@ -9,6 +9,7 @@ export const dispatchGithubBuild = action({
flags: v.string(),
version: v.string(),
buildHash: v.string(),
plugins: v.optional(v.array(v.string())),
},
handler: async (ctx, args) => {
const githubToken = process.env.GITHUB_TOKEN
@@ -37,6 +38,7 @@ export const dispatchGithubBuild = action({
build_id: args.buildId,
build_hash: args.buildHash,
convex_url: convexUrl || 'https://example.com', // Fallback to avoid missing input error if that's the cause
plugins: (args.plugins ?? []).join(' '),
},
}
+30 -4
View File
@@ -45,19 +45,23 @@ export function computeFlagsFromConfig(config: BuildConfigFields): string {
}
/**
* Computes a stable SHA-256 hash from version, target, and flags.
* Computes a stable SHA-256 hash from version, target, flags, and plugins.
* Internal helper for hash computation.
*/
async function computeBuildHashInternal(
version: string,
target: string,
flags: string
flags: string,
plugins: string[]
): Promise<string> {
// Input is now the exact parameters used for the build
// Sort plugins array for consistent hashing
const sortedPlugins = [...plugins].sort()
const input = JSON.stringify({
version,
target,
flags,
plugins: sortedPlugins,
})
// Use Web Crypto API for SHA-256 hashing
@@ -78,10 +82,12 @@ export async function computeBuildHash(
config: BuildConfigFields
): Promise<{ hash: string; flags: string }> {
const flags = computeFlagsFromConfig(config)
const plugins = config.pluginsEnabled ?? []
const hash = await computeBuildHashInternal(
config.version,
config.target,
flags
flags,
plugins
)
return { hash, flags }
}
@@ -146,6 +152,7 @@ export const upsertBuild = internalMutation({
buildId,
flags,
buildHash,
plugins: config.pluginsEnabled ?? [],
})
return buildId
@@ -157,6 +164,7 @@ export const ensureBuildFromConfig = mutation({
target: v.string(),
version: v.string(),
modulesExcluded: v.optional(v.record(v.string(), v.boolean())),
pluginsEnabled: v.optional(v.array(v.string())),
profileName: v.optional(v.string()),
profileDescription: v.optional(v.string()),
},
@@ -166,6 +174,7 @@ export const ensureBuildFromConfig = mutation({
version: args.version,
modulesExcluded: args.modulesExcluded ?? {},
target: args.target,
pluginsEnabled: args.pluginsEnabled,
}
// Compute build hash (single source of truth)
@@ -299,6 +308,13 @@ async function generateAuthenticatedDownloadUrl(
flashCount: nextCount,
updatedAt: Date.now(),
})
// Increment plugin flash counts if build has plugins enabled
if (build.config.pluginsEnabled && build.config.pluginsEnabled.length > 0) {
await ctx.runMutation(internal.plugins.incrementFlashCount, {
slugs: build.config.pluginsEnabled,
})
}
}
// Slugify profile name for filename
@@ -377,7 +393,17 @@ export const generateAnonymousDownloadUrl = mutation({
build: v.object(buildFields),
slug: v.string(),
},
handler: async (_ctx, args) => {
handler: async (ctx, args) => {
// Increment plugin flash counts if build has plugins enabled
if (
args.build.config.pluginsEnabled &&
args.build.config.pluginsEnabled.length > 0
) {
await ctx.runMutation(internal.plugins.incrementFlashCount, {
slugs: args.build.config.pluginsEnabled,
})
}
let objectKey = args.build.artifactPath || ''
if (objectKey.startsWith('/')) {
objectKey = objectKey.substring(1)
+52
View File
@@ -0,0 +1,52 @@
import { v } from 'convex/values'
import { internalMutation, query } from './_generated/server'
export const get = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
const plugin = await ctx.db
.query('plugins')
.withIndex('by_slug', (q) => q.eq('slug', args.slug))
.unique()
return plugin
? { slug: plugin.slug, flashCount: plugin.flashCount }
: { slug: args.slug, flashCount: 0 }
},
})
export const getAll = query({
args: {},
handler: async (ctx) => {
const plugins = await ctx.db.query('plugins').collect()
const counts: Record<string, number> = {}
for (const plugin of plugins) {
counts[plugin.slug] = plugin.flashCount
}
return counts
},
})
export const incrementFlashCount = internalMutation({
args: { slugs: v.array(v.string()) },
handler: async (ctx, args) => {
for (const slug of args.slugs) {
const existing = await ctx.db
.query('plugins')
.withIndex('by_slug', (q) => q.eq('slug', slug))
.unique()
if (existing) {
await ctx.db.patch(existing._id, {
flashCount: existing.flashCount + 1,
updatedAt: Date.now(),
})
} else {
await ctx.db.insert('plugins', {
slug,
flashCount: 1,
updatedAt: Date.now(),
})
}
}
},
})
+8
View File
@@ -7,6 +7,7 @@ export const buildConfigFields = {
version: v.string(),
modulesExcluded: v.record(v.string(), v.boolean()),
target: v.string(),
pluginsEnabled: v.optional(v.array(v.string())),
}
export const profileFields = {
@@ -32,10 +33,17 @@ export const buildFields = {
githubRunId: v.optional(v.number()),
}
export const pluginFields = {
slug: v.string(),
flashCount: v.number(),
updatedAt: v.number(),
}
export const schema = defineSchema({
...authTables,
profiles: defineTable(profileFields),
builds: defineTable(buildFields),
plugins: defineTable(pluginFields).index('by_slug', ['slug']),
})
export type ProfilesDoc = Doc<'profiles'>
+4
View File
@@ -3,8 +3,10 @@
"name": "LoDB",
"description": "Micro database for Meshtastic - A synchronous, protobuf-based database for Meshtastic",
"repo": "https://github.com/MeshEnvy/lodb",
"homepage": "https://github.com/MeshEnvy/lodb",
"version": "1.0.0",
"author": "benallfree",
"featured": false,
"dependencies": {
"meshtastic": ">=2.7.0"
}
@@ -14,7 +16,9 @@
"author": "benallfree",
"description": "BBS for Meshtastic right on the firmware - A full bulletin board system that runs entirely inside the Meshtastic firmware",
"repo": "https://github.com/MeshEnvy/lobbs",
"homepage": "https://github.com/MeshEnvy/lobbs",
"version": "1.0.0",
"featured": true,
"dependencies": {
"lodb": ">=1.0.0",
"meshtastic": ">=2.7.0"
+75
View File
@@ -0,0 +1,75 @@
import { ExternalLink, Star } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
interface PluginToggleProps {
id: string
name: string
description: string
isEnabled: boolean
onToggle: (enabled: boolean) => void
featured?: boolean
flashCount?: number
homepage?: string
}
export function PluginToggle({
name,
description,
isEnabled,
onToggle,
featured = false,
flashCount = 0,
homepage,
}: PluginToggleProps) {
return (
<div className="relative flex items-start gap-4 p-4 rounded-lg border-2 border-slate-700 bg-slate-900/50 hover:border-slate-600 transition-colors">
{/* Flash count and homepage links in lower right */}
<div className="absolute bottom-2 right-2 flex items-center gap-3 text-xs text-slate-400 z-10">
<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>
{homepage && (
<a
href={homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-slate-400 hover:text-slate-300 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 mb-1">
<h4 className="font-semibold text-sm">{name}</h4>
{featured && (
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
)}
</div>
<p className="text-xs text-slate-400 leading-relaxed">{description}</p>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Switch
checked={isEnabled}
onCheckedChange={onToggle}
labelLeft="Excluded"
labelRight="Included"
className={isEnabled ? 'bg-green-600' : 'bg-slate-600'}
/>
</div>
</div>
)
}
+1 -1
View File
@@ -26,7 +26,7 @@ export function Switch({
onClick={() => !disabled && onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-8 w-24 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-red-600' : 'bg-slate-600',
!className && (checked ? 'bg-red-600' : 'bg-slate-600'),
className
)}
>
+105 -2
View File
@@ -1,12 +1,14 @@
import { useMutation } from 'convex/react'
import { useMutation, useQuery } from 'convex/react'
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { ModuleToggle } from '@/components/ModuleToggle'
import { PluginToggle } from '@/components/PluginToggle'
import { Button } from '@/components/ui/button'
import { api } from '../../convex/_generated/api'
import modulesData from '../../convex/modules.json'
import registryData from '../../registry/registry.json'
import { TARGETS } from '../constants/targets'
import { VERSIONS } from '../constants/versions'
@@ -34,6 +36,7 @@ const DEFAULT_TARGET =
export default function BuildNew() {
const navigate = useNavigate()
const ensureBuildFromConfig = useMutation(api.builds.ensureBuildFromConfig)
const pluginFlashCounts = useQuery(api.plugins.getAll) ?? {}
const STORAGE_KEY = 'quick_build_target'
const persistTargetSelection = (targetId: string) => {
@@ -51,9 +54,11 @@ export default function BuildNew() {
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)
useEffect(() => {
if (!activeCategory && TARGET_CATEGORIES.length > 0) {
@@ -107,6 +112,9 @@ export default function BuildNew() {
}, [selectedTarget])
const moduleCount = Object.keys(moduleConfig).length
const pluginCount = Object.keys(pluginConfig).filter(
(id) => pluginConfig[id] === true
).length
const selectedTargetLabel =
(selectedTarget && TARGETS[selectedTarget]?.name) || selectedTarget
@@ -122,15 +130,31 @@ export default function BuildNew() {
})
}
const handleTogglePlugin = (id: string, enabled: boolean) => {
setPluginConfig((prev) => {
const next = { ...prev }
if (enabled) {
next[id] = true
} else {
delete next[id]
}
return next
})
}
const handleFlash = async () => {
if (!selectedTarget) return
setIsFlashing(true)
setErrorMessage(null)
try {
const pluginsEnabled = Object.keys(pluginConfig).filter(
(id) => pluginConfig[id] === true
)
const result = await ensureBuildFromConfig({
target: selectedTarget,
version: selectedVersion,
modulesExcluded: moduleConfig,
pluginsEnabled: pluginsEnabled.length > 0 ? pluginsEnabled : undefined,
})
navigate(`/builds/${result.buildHash}`)
} catch (error) {
@@ -240,7 +264,7 @@ export default function BuildNew() {
className="w-full flex items-center justify-between text-left"
>
<div>
<p className="text-sm font-medium">Module overrides</p>
<p className="text-sm font-medium">Core Modules</p>
<p className="text-xs text-slate-400">
{moduleCount === 0
? 'Using default modules for this target.'
@@ -256,6 +280,14 @@ export default function BuildNew() {
{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"
@@ -284,6 +316,77 @@ export default function BuildNew() {
)}
</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">
{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]) => (
<PluginToggle
key={slug}
id={slug}
name={plugin.name}
description={plugin.description}
isEnabled={pluginConfig[slug] === true}
onToggle={(enabled) =>
handleTogglePlugin(slug, enabled)
}
featured={plugin.featured ?? false}
flashCount={pluginFlashCounts[slug] ?? 0}
homepage={plugin.homepage}
/>
))}
</div>
</div>
)}
</div>
<div className="space-y-2">
<Button
onClick={handleFlash}