diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index b28e599..1488af1 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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; }>; diff --git a/convex/actions.ts b/convex/actions.ts index 06ded00..84d3a2c 100644 --- a/convex/actions.ts +++ b/convex/actions.ts @@ -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(' '), }, } diff --git a/convex/builds.ts b/convex/builds.ts index f8c4721..1f03ab6 100644 --- a/convex/builds.ts +++ b/convex/builds.ts @@ -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 { // 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) diff --git a/convex/plugins.ts b/convex/plugins.ts new file mode 100644 index 0000000..6f36d70 --- /dev/null +++ b/convex/plugins.ts @@ -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 = {} + 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(), + }) + } + } + }, +}) diff --git a/convex/schema.ts b/convex/schema.ts index d5900f5..619a772 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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'> diff --git a/registry/registry.json b/registry/registry.json index 20e9758..1e0866f 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -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" diff --git a/src/components/PluginToggle.tsx b/src/components/PluginToggle.tsx new file mode 100644 index 0000000..229d9c6 --- /dev/null +++ b/src/components/PluginToggle.tsx @@ -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 ( +
+ {/* Flash count and homepage links in lower right */} +
+
+ + + + {flashCount} +
+ {homepage && ( + e.stopPropagation()} + > + + + )} +
+
+
+

{name}

+ {featured && ( + + )} +
+

{description}

+
+
+ +
+
+ ) +} diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index acb7872..3d2702c 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -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 )} > diff --git a/src/pages/BuildNew.tsx b/src/pages/BuildNew.tsx index 75a3878..079da78 100644 --- a/src/pages/BuildNew.tsx +++ b/src/pages/BuildNew.tsx @@ -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(DEFAULT_TARGET) const [selectedVersion, setSelectedVersion] = useState(VERSIONS[0]) const [moduleConfig, setModuleConfig] = useState>({}) + const [pluginConfig, setPluginConfig] = useState>({}) const [isFlashing, setIsFlashing] = useState(false) const [errorMessage, setErrorMessage] = useState(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" >
-

Module overrides

+

Core Modules

{moduleCount === 0 ? 'Using default modules for this target.' @@ -256,6 +280,14 @@ export default function BuildNew() { {showModuleOverrides && (

+
+

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

+
+ + {showPlugins && ( +
+
+

+ Plugins are 3rd party add-ons. They are not maintained, + endorsed, or supported by Meshtastic. Use at your own risk. +

+
+
+ +
+
+ {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]) => ( + + handleTogglePlugin(slug, enabled) + } + featured={plugin.featured ?? false} + flashCount={pluginFlashCounts[slug] ?? 0} + homepage={plugin.homepage} + /> + ))} +
+
+ )} +
+