feat: enhance library installation to support Wokwi-hosted libraries and update tests accordingly
parent
6bd2f39b8e
commit
3fe71b57af
|
|
@ -439,8 +439,13 @@ class ArduinoCLIService:
|
||||||
|
|
||||||
async def install_library(self, library_name: str) -> dict:
|
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:
|
try:
|
||||||
print(f"Installing library: {library_name}")
|
print(f"Installing library: {library_name}")
|
||||||
|
|
||||||
|
|
@ -463,6 +468,115 @@ class ArduinoCLIService:
|
||||||
print(f"Exception installing library: {e}")
|
print(f"Exception installing library: {e}")
|
||||||
return {"success": False, "error": str(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:
|
async def list_installed_libraries(self) -> dict:
|
||||||
"""
|
"""
|
||||||
List all installed Arduino libraries
|
List all installed Arduino libraries
|
||||||
|
|
|
||||||
|
|
@ -98,11 +98,11 @@ describe('parseLibrariesTxt — unit', () => {
|
||||||
expect(result).toEqual(['LibA', 'LibB', 'LibC']);
|
expect(result).toEqual(['LibA', 'LibB', 'LibC']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes @wokwi: hash entries', () => {
|
it('includes @wokwi: hash entries (backend handles them)', () => {
|
||||||
const result = parseLibrariesTxt(
|
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)', () => {
|
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']);
|
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);
|
const result = parseLibrariesTxt(CALCULATOR_LIBRARIES_TXT);
|
||||||
expect(result).toContain('Adafruit GFX Library');
|
expect(result).toContain('Adafruit GFX Library');
|
||||||
expect(result).toContain('Adafruit FT6206 Library');
|
expect(result).toContain('Adafruit FT6206 Library');
|
||||||
expect(result).toContain('Adafruit ILI9341');
|
expect(result).toContain('Adafruit ILI9341');
|
||||||
expect(result).toContain('SD');
|
expect(result).toContain('SD');
|
||||||
expect(result).toContain('Adafruit SSD1351 library');
|
expect(result).toContain('Adafruit SSD1351 library');
|
||||||
// none of the @wokwi: entries
|
// Wokwi-hosted entries must also be present
|
||||||
for (const entry of result) {
|
expect(result).toContain('LC_Adafruit_1947@wokwi:b065451f35dab6e1021d78f0f79b6eda6910455d');
|
||||||
expect(entry).not.toContain('@wokwi:');
|
expect(result).toContain('LC_baseTools@wokwi:95340986110645c1b45e55597a7caf4d023d4b4a');
|
||||||
}
|
// 5 standard + 10 @wokwi: entries visible in CALCULATOR_LIBRARIES_TXT snippet
|
||||||
expect(result).toHaveLength(5);
|
expect(result.length).toBeGreaterThan(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses ServoOverdone libraries.txt → [Servo]', () => {
|
it('parses ServoOverdone libraries.txt → [Servo]', () => {
|
||||||
|
|
@ -153,13 +153,13 @@ describe('importFromWokwiZip — libraries field', () => {
|
||||||
expect(result.libraries).toEqual(['Adafruit GFX Library', 'Adafruit SSD1306']);
|
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({
|
const file = await makeZip({
|
||||||
'sketch.ino': 'void setup(){}void loop(){}',
|
'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);
|
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 () => {
|
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']);
|
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({
|
const file = await makeZip({
|
||||||
'sketch.ino': 'void setup(){}void loop(){}',
|
'sketch.ino': 'void setup(){}void loop(){}',
|
||||||
'libraries.txt': CALCULATOR_LIBRARIES_TXT,
|
'libraries.txt': CALCULATOR_LIBRARIES_TXT,
|
||||||
});
|
});
|
||||||
const result = await importFromWokwiZip(file);
|
const result = await importFromWokwiZip(file);
|
||||||
expect(result.libraries).toHaveLength(5);
|
// 5 standard Arduino Library Manager libs
|
||||||
for (const lib of result.libraries) {
|
expect(result.libraries).toContain('Adafruit GFX Library');
|
||||||
expect(lib).not.toContain('@wokwi:');
|
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 () => {
|
it('libraries field does not interfere with files[], components[], wires[]', async () => {
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,17 @@
|
||||||
cursor: help;
|
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 */
|
/* Spinner */
|
||||||
.ilib-spinner {
|
.ilib-spinner {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,20 @@ export const InstallLibrariesModal: React.FC<InstallLibrariesModalProps> = ({
|
||||||
|
|
||||||
{/* Library list */}
|
{/* Library list */}
|
||||||
<div className="ilib-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}`}>
|
<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">
|
<span className="ilib-item-status">
|
||||||
{item.status === 'pending' && <span className="ilib-badge ilib-badge--pending">pending</span>}
|
{item.status === 'pending' && <span className="ilib-badge ilib-badge--pending">pending</span>}
|
||||||
{item.status === 'installing' && (
|
{item.status === 'installing' && (
|
||||||
|
|
@ -156,7 +167,8 @@ export const InstallLibrariesModal: React.FC<InstallLibrariesModalProps> = ({
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ export interface ImportResult {
|
||||||
components: VelxioComponent[];
|
components: VelxioComponent[];
|
||||||
wires: Wire[];
|
wires: Wire[];
|
||||||
files: Array<{ name: string; content: string }>;
|
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[];
|
libraries: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,14 +137,14 @@ function metadataIdToWokwiType(metadataId: string): string {
|
||||||
/**
|
/**
|
||||||
* Parse the contents of a Wokwi libraries.txt file.
|
* Parse the contents of a Wokwi libraries.txt file.
|
||||||
* - Strips blank lines and # comments
|
* - Strips blank lines and # comments
|
||||||
* - Excludes Wokwi-hosted entries in the form name@wokwi:hash
|
* - Includes Wokwi-hosted entries in the form name@wokwi:hash
|
||||||
* (they have no arduino-cli installable equivalent)
|
* so the backend can download and install them from wokwi.com
|
||||||
*/
|
*/
|
||||||
export function parseLibrariesTxt(content: string): string[] {
|
export function parseLibrariesTxt(content: string): string[] {
|
||||||
const libs: string[] = [];
|
const libs: string[] = [];
|
||||||
for (const raw of content.split('\n')) {
|
for (const raw of content.split('\n')) {
|
||||||
const line = raw.trim();
|
const line = raw.trim();
|
||||||
if (!line || line.startsWith('#') || line.includes('@wokwi:')) continue;
|
if (!line || line.startsWith('#')) continue;
|
||||||
libs.push(line);
|
libs.push(line);
|
||||||
}
|
}
|
||||||
return libs;
|
return libs;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue