fix(boards): a QEMU-Linux overlay board is not a Raspberry Pi

Two answers about the UNIHIKER M10 came from the family whose run path it
borrows rather than from the board itself.

The upgrade prompt told a free user that "Raspberry Pi emulation is a Pro
feature" about a DFRobot SBC: proBoardFeatureName named the family, so every
piFamily overlay board inherited the Pi's name. It now uses the board's own
label whenever the kind is registered; the OSS families keep theirs.

loadExample gated the guest-script branch on isPiBoardKind(), which reads a
registry the overlay only fills at mount. A direct /example/<id> link could
load before that: the branch was skipped and the editor opened on the board
group's Arduino blink default instead of the example's script.py. vfsFiles is
itself the declaration that this board's code is a guest script, so the
branch no longer consults the registry.

The server-side gate message names the two families instead of one board,
since the QEMU-Linux lane also carries boards that are not Raspberry Pis.

Test covers both, with the unregistered-kind race reproduced.
This commit is contained in:
David Montero 2026-08-25 19:22:29 +02:00
parent 0430e8e0d4
commit 3348e1fa48
4 changed files with 117 additions and 4 deletions

View File

@ -30,9 +30,14 @@ BoardAccessGate = Callable[[object, str], Awaitable[bool]]
_gate: Optional[BoardAccessGate] = None
# Deliberately names the families, not one board: the QEMU-Linux lane also
# carries boards that are not Raspberry Pis (the UNIHIKER M10 boots through
# the same bridge), and telling their user that "Raspberry Pi emulation" is
# gated is a message about a board they did not place.
PRO_BOARD_MESSAGE = (
'STM32 and Raspberry Pi emulation are Velxio Pro features. '
'Use them on velxio.dev with a paid plan, or install Velxio Desktop.'
'STM32 and QEMU-emulated Linux boards (Raspberry Pi, UNIHIKER) are '
'Velxio Pro features. Use them on velxio.dev with a paid plan, or '
'install Velxio Desktop.'
)

View File

@ -0,0 +1,90 @@
// @vitest-environment jsdom
/**
* Overlay QEMU-Linux boards (ProBoardDef.piFamily) in the example loader and
* in the Pro upgrade prompt.
*
* Both used to answer as if the board were a Raspberry Pi, because both read
* a registry the overlay only fills at mount:
*
* - `loadExample` gated the guest-script branch on `isPiBoardKind()`. A
* direct /example/<id> link can load before the overlay registers its
* kinds (the pro overlay registered the partner examples first), so the
* branch was skipped and the editor opened on the board group's Arduino
* blink default instead of the example's script.py.
* - `proBoardFeatureName` named the FAMILY, so a free user clicking Run on
* a UNIHIKER M10 was told "Raspberry Pi emulation is a Pro feature" about
* a board that is not a Raspberry Pi.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { useEditorStore } from '../store/useEditorStore';
import { useElectricalStore } from '../store/useElectricalStore';
import { registerProBoards } from '../lib/proBoardRegistry';
import { proBoardFeatureName } from '../lib/proBoardGate';
import { loadExample } from '../utils/loadExample';
import type { ExampleProject } from '../data/examples';
import type { BoardKind } from '../types/board';
// Never registered — stands in for a board whose overlay import has not
// landed yet at load time.
const UNREGISTERED = 'linux-test-unregistered';
// Registered below, as the overlay does at mount.
const REGISTERED = 'linux-test-registered';
const SCRIPT = '# guest script\nprint("hello from the guest")\n';
const exampleFor = (kind: string): ExampleProject =>
({
id: `example-${kind}`,
title: `${kind} example`,
description: 'guest script uploaded to the QEMU-Linux board',
category: 'basics',
difficulty: 'beginner',
boardType: kind,
code: '',
boards: [{ boardKind: kind, x: 40, y: 40, vfsFiles: { 'script.py': SCRIPT } }],
components: [],
wires: [],
}) as unknown as ExampleProject;
function resetStores() {
const sim = useSimulatorStore.getState();
for (const id of sim.boards.map((b) => b.id)) sim.removeBoard(id);
useElectricalStore.getState().setPaused(false);
}
describe('overlay QEMU-Linux boards', () => {
beforeEach(resetStores);
it('loads the example guest script even before the kind is registered', async () => {
await loadExample(exampleFor(UNREGISTERED));
const sim = useSimulatorStore.getState();
expect(sim.boards).toHaveLength(1);
const files = useEditorStore.getState().fileGroups[sim.boards[0].activeFileGroupId] ?? [];
expect(files.map((f) => f.name)).toContain('script.py');
expect(files.find((f) => f.name === 'script.py')?.content).toBe(SCRIPT);
// The board group's Arduino default must be gone, not sitting next to it.
expect(files.some((f) => (f.content ?? '').includes('Arduino Blink Example'))).toBe(false);
});
it('names the board itself in the upgrade prompt, not the family it rides', () => {
registerProBoards([
{
kind: REGISTERED,
label: 'Test Linux SBC',
fqbn: null,
description: 'overlay QEMU-Linux board',
tag: 'velxio-linux-test',
size: { w: 100, h: 100 },
piFamily: true,
},
]);
expect(proBoardFeatureName(REGISTERED)).toBe('Test Linux SBC emulation');
// The OSS families keep their family names — every kind in them is one.
expect(proBoardFeatureName('raspberry-pi-4' as BoardKind)).toBe('Raspberry Pi emulation');
expect(proBoardFeatureName('stm32-bluepill' as BoardKind)).toBe('STM32 emulation');
expect(proBoardFeatureName('arduino-uno' as BoardKind)).toBe('this board');
});
});

View File

@ -23,6 +23,7 @@
import type { BoardKind } from '../types/board';
import { isPiBoardKind, isStm32BoardKind } from '../types/board';
import { getProBoard } from './proBoardRegistry';
export type BoardGateDecision = 'allow' | 'block';
@ -82,8 +83,18 @@ export function triggerProUpgradePrompt(featureName: string): void {
);
}
/** Human label for the upgrade prompt, per board family. */
/**
* Human label for the upgrade prompt.
*
* An overlay board is named by its own label, not by the family whose run
* path it borrows: `piFamily` routes the UNIHIKER M10 through the Raspberry
* Pi bridge, and the prompt told its user that "Raspberry Pi emulation is a
* Pro feature" about a board that is not a Raspberry Pi. The OSS families
* keep their family names — every kind in them really is one.
*/
export function proBoardFeatureName(kind: BoardKind | string): string {
const def = typeof kind === 'string' ? getProBoard(kind) : undefined;
if (def) return `${def.label} emulation`;
if (isStm32BoardKind(kind)) return 'STM32 emulation';
if (isPiBoardKind(kind)) return 'Raspberry Pi emulation';
return 'this board';

View File

@ -189,7 +189,14 @@ export async function loadExample(
useSimulatorStore.getState().updateBoard(boardId, { sdFiles: eb.sdFiles });
}
if (eb.vfsFiles && isPiBoardKind(eb.boardKind)) {
// `vfsFiles` IS the declaration that this board's code is a guest
// script — no isPiBoardKind() check. That guard read the Pi-family
// registry, which an overlay fills at mount: a direct /example/<id>
// link to an overlay QEMU-Linux board (UNIHIKER M10) could load before
// its kind was registered, the branch was skipped, and the editor
// opened on the board group's Arduino blink default instead of the
// example's script. Only QEMU-Linux examples carry vfsFiles.
if (eb.vfsFiles) {
// Pi example scripts go into the board's REGULAR file group — they
// are edited in Monaco like any other board's code, and the run
// path uploads the group into the guest home. (The old separate