feat: enhance library installation to support Wokwi-hosted libraries and update tests accordingly

pull/10/head
David Montero Crespo 2026-03-10 01:25:49 -03:00
parent 6bd2f39b8e
commit 3fe71b57af
5 changed files with 164 additions and 25 deletions

View File

@ -439,8 +439,13 @@ class ArduinoCLIService:
async def install_library(self, library_name: str) -> dict:
"""
Install an Arduino library
Install an Arduino library.
Handles standard library names as well as Wokwi-hosted entries in
the form "LibName@wokwi:projectHash".
"""
if '@wokwi:' in library_name:
return await self._install_wokwi_library(library_name)
try:
print(f"Installing library: {library_name}")
@ -463,6 +468,115 @@ class ArduinoCLIService:
print(f"Exception installing library: {e}")
return {"success": False, "error": str(e)}
async def _install_wokwi_library(self, library_spec: str) -> dict:
"""
Download and install a Wokwi-hosted library.
Wokwi stores custom libraries as projects. The spec format is:
LibName@wokwi:projectHash
and the project ZIP is available at:
https://wokwi.com/api/projects/{projectHash}/zip
The ZIP is extracted into the Arduino user libraries directory so that
arduino-cli can find the headers during compilation.
"""
import json as _json
import urllib.request
import urllib.error
import zipfile
import os
import shutil
parts = library_spec.split('@wokwi:', 1)
lib_name = parts[0].strip()
project_hash = parts[1].strip()
print(f"Installing Wokwi library: {lib_name} (project: {project_hash})")
# ── Locate the Arduino user libraries directory ────────────────────────
try:
def _get_config():
return subprocess.run(
[self.cli_path, "config", "dump", "--format", "json"],
capture_output=True, text=True, encoding='utf-8', errors='replace'
)
cfg_result = await asyncio.to_thread(_get_config)
cfg = _json.loads(cfg_result.stdout)
config_dict = cfg.get("config", cfg)
dirs = config_dict.get("directories", {})
user_dir = dirs.get("user", "") or dirs.get("sketchbook", "")
if not user_dir:
return {"success": False, "error": "Could not determine Arduino user directory from config"}
lib_dir = Path(user_dir) / "libraries" / lib_name
except Exception as e:
return {"success": False, "error": f"Failed to read arduino-cli config: {e}"}
# ── Download project ZIP ───────────────────────────────────────────────
url = f"https://wokwi.com/api/projects/{project_hash}/zip"
tmp_path = None
try:
with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp:
tmp_path = tmp.name
def _download():
req = urllib.request.Request(
url,
headers={"User-Agent": "velxio-arduino-emulator/1.0"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp, \
open(tmp_path, 'wb') as out:
out.write(resp.read())
except urllib.error.HTTPError as http_err:
raise RuntimeError(
f"Could not download Wokwi library '{lib_name}' "
f"(HTTP {http_err.code}). "
f"Wokwi-hosted libraries require the Wokwi platform and "
f"cannot be installed automatically in a local environment."
) from http_err
await asyncio.to_thread(_download)
# ── Extract into the libraries directory ───────────────────────────
if lib_dir.exists():
shutil.rmtree(lib_dir)
lib_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(tmp_path, 'r') as zf:
for zi in zf.infolist():
# Skip directories and Wokwi-specific files
if zi.is_dir():
continue
fname = zi.filename
basename = Path(fname).name
if not basename or basename == 'wokwi-project.txt':
continue
# Flatten any subdirectory structure
dest = lib_dir / basename
dest.write_bytes(zf.read(fname))
# Create a minimal library.properties so arduino-cli recognises it
props = lib_dir / "library.properties"
if not props.exists():
props.write_text(
f"name={lib_name}\nversion=1.0.0\nauthor=Wokwi\n"
f"sentence=Wokwi-hosted library\nparagraph=\ncategory=Other\n"
f"url=https://wokwi.com/projects/{project_hash}\n"
f"architectures=*\n"
)
print(f"Installed Wokwi library {lib_name} to {lib_dir}")
return {"success": True, "stdout": f"Installed {lib_name} from Wokwi project {project_hash}"}
except Exception as e:
print(f"Error installing Wokwi library {lib_name}: {e}")
return {"success": False, "error": str(e)}
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass
async def list_installed_libraries(self) -> dict:
"""
List all installed Arduino libraries

View File

@ -98,11 +98,11 @@ describe('parseLibrariesTxt — unit', () => {
expect(result).toEqual(['LibA', 'LibB', 'LibC']);
});
it('excludes @wokwi: hash entries', () => {
it('includes @wokwi: hash entries (backend handles them)', () => {
const result = parseLibrariesTxt(
'GoodLib\nBadLib@wokwi:abc123deadbeef\nAnotherGood\n',
'GoodLib\nWokwiLib@wokwi:abc123deadbeef\nAnotherGood\n',
);
expect(result).toEqual(['GoodLib', 'AnotherGood']);
expect(result).toEqual(['GoodLib', 'WokwiLib@wokwi:abc123deadbeef', 'AnotherGood']);
});
it('handles inline whitespace (leading/trailing spaces on a line)', () => {
@ -115,18 +115,18 @@ describe('parseLibrariesTxt — unit', () => {
expect(result).toEqual(['Adafruit GFX Library', 'Adafruit SSD1306']);
});
it('parses calculator-breakout-icon libraries.txt → only standard libs, no @wokwi:', () => {
it('parses calculator-breakout-icon libraries.txt → standard libs AND @wokwi: entries', () => {
const result = parseLibrariesTxt(CALCULATOR_LIBRARIES_TXT);
expect(result).toContain('Adafruit GFX Library');
expect(result).toContain('Adafruit FT6206 Library');
expect(result).toContain('Adafruit ILI9341');
expect(result).toContain('SD');
expect(result).toContain('Adafruit SSD1351 library');
// none of the @wokwi: entries
for (const entry of result) {
expect(entry).not.toContain('@wokwi:');
}
expect(result).toHaveLength(5);
// Wokwi-hosted entries must also be present
expect(result).toContain('LC_Adafruit_1947@wokwi:b065451f35dab6e1021d78f0f79b6eda6910455d');
expect(result).toContain('LC_baseTools@wokwi:95340986110645c1b45e55597a7caf4d023d4b4a');
// 5 standard + 10 @wokwi: entries visible in CALCULATOR_LIBRARIES_TXT snippet
expect(result.length).toBeGreaterThan(5);
});
it('parses ServoOverdone libraries.txt → [Servo]', () => {
@ -153,13 +153,13 @@ describe('importFromWokwiZip — libraries field', () => {
expect(result.libraries).toEqual(['Adafruit GFX Library', 'Adafruit SSD1306']);
});
it('excludes @wokwi: entries from the returned array', async () => {
it('includes @wokwi: entries in the returned array (backend installs them)', async () => {
const file = await makeZip({
'sketch.ino': 'void setup(){}void loop(){}',
'libraries.txt': 'GoodLib\nBadLib@wokwi:deadbeef12345\n',
'libraries.txt': 'GoodLib\nWokwiLib@wokwi:deadbeef12345\n',
});
const result = await importFromWokwiZip(file);
expect(result.libraries).toEqual(['GoodLib']);
expect(result.libraries).toEqual(['GoodLib', 'WokwiLib@wokwi:deadbeef12345']);
});
it('returns empty array when libraries.txt is only comments', async () => {
@ -180,16 +180,18 @@ describe('importFromWokwiZip — libraries field', () => {
expect(result.libraries).toEqual(['Adafruit GFX Library', 'Adafruit SSD1306']);
});
it('calculator ZIP — 5 standard libs, zero @wokwi: entries', async () => {
it('calculator ZIP — includes both standard libs AND @wokwi: entries', async () => {
const file = await makeZip({
'sketch.ino': 'void setup(){}void loop(){}',
'libraries.txt': CALCULATOR_LIBRARIES_TXT,
});
const result = await importFromWokwiZip(file);
expect(result.libraries).toHaveLength(5);
for (const lib of result.libraries) {
expect(lib).not.toContain('@wokwi:');
}
// 5 standard Arduino Library Manager libs
expect(result.libraries).toContain('Adafruit GFX Library');
expect(result.libraries).toContain('SD');
// Wokwi-hosted libs must also be present
expect(result.libraries).toContain('LC_Adafruit_1947@wokwi:b065451f35dab6e1021d78f0f79b6eda6910455d');
expect(result.libraries.length).toBeGreaterThan(5);
});
it('libraries field does not interfere with files[], components[], wires[]', async () => {

View File

@ -179,6 +179,17 @@
cursor: help;
}
.ilib-badge--wokwi {
color: #7dd3fc;
background: #0c2340;
border: 1px solid #1e4070;
margin-left: 6px;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.03em;
cursor: default;
}
/* Spinner */
.ilib-spinner {
flex-shrink: 0;

View File

@ -126,9 +126,20 @@ export const InstallLibrariesModal: React.FC<InstallLibrariesModalProps> = ({
{/* Library list */}
<div className="ilib-list">
{items.map((item) => (
{items.map((item) => {
// For Wokwi-hosted libraries ("LibName@wokwi:hash"), show only the LibName
const displayName = item.name.includes('@wokwi:')
? item.name.split('@wokwi:')[0]
: item.name;
const isWokwiLib = item.name.includes('@wokwi:');
return (
<div key={item.name} className={`ilib-item ilib-item--${item.status}`}>
<span className="ilib-item-name">{item.name}</span>
<span className="ilib-item-name">
{displayName}
{isWokwiLib && (
<span className="ilib-badge ilib-badge--wokwi" title="Wokwi-hosted library">wokwi</span>
)}
</span>
<span className="ilib-item-status">
{item.status === 'pending' && <span className="ilib-badge ilib-badge--pending">pending</span>}
{item.status === 'installing' && (
@ -156,7 +167,8 @@ export const InstallLibrariesModal: React.FC<InstallLibrariesModalProps> = ({
)}
</span>
</div>
))}
);
})}
</div>
{/* Footer */}

View File

@ -48,7 +48,7 @@ export interface ImportResult {
components: VelxioComponent[];
wires: Wire[];
files: Array<{ name: string; content: string }>;
/** Standard Arduino library names parsed from libraries.txt (Wokwi-only @wokwi: entries are excluded). */
/** Library names parsed from libraries.txt. Includes both standard Arduino Library Manager names and Wokwi-hosted entries in the form "LibName@wokwi:hash". */
libraries: string[];
}
@ -137,14 +137,14 @@ function metadataIdToWokwiType(metadataId: string): string {
/**
* Parse the contents of a Wokwi libraries.txt file.
* - Strips blank lines and # comments
* - Excludes Wokwi-hosted entries in the form name@wokwi:hash
* (they have no arduino-cli installable equivalent)
* - Includes Wokwi-hosted entries in the form name@wokwi:hash
* so the backend can download and install them from wokwi.com
*/
export function parseLibrariesTxt(content: string): string[] {
const libs: string[] = [];
for (const raw of content.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('#') || line.includes('@wokwi:')) continue;
if (!line || line.startsWith('#')) continue;
libs.push(line);
}
return libs;