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:
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue